From 5cb83ccb0cae391f6bb32a0c06d33c14b7348a56 Mon Sep 17 00:00:00 2001 From: Subodh Date: Thu, 30 Jul 2026 08:15:50 +0530 Subject: [PATCH 01/30] add replay-gated generalist v3 --- .gitattributes | 5 +- CLAUDE.md | 56 ++ HANDOFF.md | 66 ++ WORKFLOW.md | 85 +- bots/champion/coreHelper.py | 32 - bots/champion/defend.py | 119 --- bots/champion/initialSpawning.py | 120 +++ bots/champion/main.py | 563 ++++++++++++ bots/champion/mapPathfinding.py | 392 +++++++++ bots/champion/pathfinding.py | 156 ---- bots/generalist-v2/initialSpawning.py | 120 +++ bots/generalist-v2/main.py | 1093 +++++++++++++++++++++++ bots/generalist-v2/mapPathfinding.py | 392 +++++++++ bots/generalist-v2/symmetry.py | 116 +++ bots/generalist-v3/README.md | 25 + bots/generalist-v3/initialSpawning.py | 120 +++ bots/generalist-v3/main.py | 1175 +++++++++++++++++++++++++ bots/generalist-v3/mapPathfinding.py | 392 +++++++++ bots/generalist-v3/symmetry.py | 116 +++ gate.py | 217 ++++- generalist-v3.zip | Bin 0 -> 17993 bytes generalist_v3_results.json | 48 + live_baseline.json | 10 + live_replay_audit.py | 383 ++++++++ maps/jackpot.map26 | Bin 350 -> 350 bytes 25 files changed, 5446 insertions(+), 355 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 bots/champion/coreHelper.py delete mode 100644 bots/champion/defend.py create mode 100644 bots/champion/initialSpawning.py create mode 100644 bots/champion/main.py create mode 100644 bots/champion/mapPathfinding.py delete mode 100644 bots/champion/pathfinding.py create mode 100644 bots/generalist-v2/initialSpawning.py create mode 100644 bots/generalist-v2/main.py create mode 100644 bots/generalist-v2/mapPathfinding.py create mode 100644 bots/generalist-v2/symmetry.py create mode 100644 bots/generalist-v3/README.md create mode 100644 bots/generalist-v3/initialSpawning.py create mode 100644 bots/generalist-v3/main.py create mode 100644 bots/generalist-v3/mapPathfinding.py create mode 100644 bots/generalist-v3/symmetry.py create mode 100644 generalist-v3.zip create mode 100644 generalist_v3_results.json create mode 100644 live_baseline.json create mode 100644 live_replay_audit.py diff --git a/.gitattributes b/.gitattributes index dfe0770..7f18a0b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ -# Auto detect text files and perform LF normalization -* text=auto +bots/champion/*.py whitespace=-blank-at-eol +bots/generalist-v2/*.py whitespace=-blank-at-eol +bots/generalist-v3/*.py whitespace=-blank-at-eol diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..26bdc9c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# Claude operating brief + +Read `HANDOFF.md` first, then `WORKFLOW.md`. Keep both current by rewriting +sections in place; never append diary-style logs. + +## Current state + +- Live submission: v89, source frozen at `bots/generalist-v2`. +- Next local candidate: `bots/generalist-v3`; package + `generalist-v3.zip`; evidence `generalist_v3_results.json`. +- Root sources and `bot.zip` are historical v88. Do not replace them. +- Engine: staging `fcode 2.3.2.dev29`, 21 synced maps. +- Never upload, queue matches, activate, or replace root without fresh, + explicit user approval. + +## Required iteration method + +1. Plan before code. Classify new losses across unrelated opponents/maps into + general failure mechanisms. +2. Never fingerprint map name, dimensions, side order, opponent, or one replay. + React only to observable state. +3. Copy the frozen parent into one independent experiment per hypothesis. + Preserve openings and unrelated behavior. +4. Add deterministic mechanism tests. For rare behavior, instrument a + disposable trace copy and require replay proof that it actually activated; + reject win-rate changes from inactive code as noise. +5. Gate in order: compile/protocol tests; 12-game crash screen; 168-game + both-side parent gate; 168 games each against champion, lastpop2, and + OogwayOld. Require nonnegative core-kill differential and no opponent + regression over five percentage points from a fresh control. +6. Stack only independently passing changes, one at a time, then rerun every + gate. Record rejected mechanisms and why. +7. Freeze/package winners separately; verify ZIP contents byte-for-byte, + `git diff --check`, and attempt the legacy evaluator while reporting its + known multi-file import incompatibility honestly. + +Use `live_replay_audit.py` for batch replay categories and `gate.py` for dev29 +JSON metrics/private replay paths. Treat ladder results as confirmation, never +as the sole promotion signal. Preserve unrelated dirty-worktree changes and +stage explicit paths only. + +## Commands + +Run the real engine in WSL: + +```bash +cd /mnt/c/Users/subodh/Downloads/fcode +source ~/.venvs/fcode/bin/activate +python gate.py generalist-v3 generalist-v2 4 quiet +python gate.py generalist-v3 champion 4 quiet +python gate.py generalist-v3 lastpop2 4 quiet +python gate.py generalist-v3 OogwayOld 4 quiet +``` + +The exact v3 results, hashes, caveat about second-family activation, and next +remote-validation decision are at the top of `HANDOFF.md`. diff --git a/HANDOFF.md b/HANDOFF.md index 39081f7..43c4ae6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,6 +2,72 @@ Snapshot for a fresh session. Read `memory/merged-team-state.md` + `memory/florent-league-workflow.md` first (auto-loaded), then this, then the top of `florent-code-league.md` for the full IC3D bot history. +## PERMANENT ITERATION POLICY + +Plan before every non-trivial iteration. Diagnose losses across maps and +unrelated opponents as general weakness categories; never tune by map name, +dimensions, side order, or opponent identity. Build hypotheses independently +from the frozen baseline, require mechanism tests plus graduated parent and +multi-opponent gates, and stack only winners one at a time with a full re-gate +after each addition. Preserve root/active artifacts and require explicit user +approval for upload, queueing, replacement, or activation. See `WORKFLOW.md` +for the mandatory evidence ladder. + +## CURRENT LOCAL CANDIDATE (2026-07-29, `bots/generalist-v3`, not uploaded) + +- **Generalist v3 = live v89 source + one bounded emergency countertrade.** + The existing single nearby responder is unchanged during ordinary threats. + Only when at least two visible turrets really hit the core, no fully safe + counter exists, and the builder stand is outside every attack pattern may it + place one gun whose seat is covered by at most one turret. The generic + 120-Ti reserve is waived only for that trade. Openings, economy, siege, + spawning, roles, stores, map logic, and distant builders are unchanged. +- Replay diagnosis: 8/14 live v89 losses across Ijti, Oogway, and + lastpopperian_ had multi-turret home pressure. The old bounded planner found + valid plans but all 15 pool-wide attempts were blocked at 74-90 Ti by the + reserve. The exception produced **4 real builds from 20 plans** in a + 16-game champion/Showdown mechanism sample, including destroyed-gun rebuilds + at 85/59/37 Ti. Second-family builds remain **unconfirmed**: lastpop2 reached + the planner but never completed the route; do not overstate causality. +- Dev29 gates, 21 maps x 4 seeds x both sides: + **90-78 (54%, kills 79-69) vs live-v89 source**; + **135-33 (80%, 120-21) vs champion**; + **153-15 (91%, 147-14) vs lastpop2**; + **146-22 (87%, 143-15) vs OogwayOld**. + Fresh live-v89-source controls were 74% / 89% / 85%, so all five-point + regression floors pass. Only the champion gain has mechanism support; + treat the other small gains as variance. +- Frozen/package: `bots/generalist-v3`, `generalist-v3.zip`, and + `generalist_v3_results.json` (four Python files at ZIP root). Dev29 + compilation/import, deterministic tests, protocol tests, package identity, + root-v88 identity, and `git diff --check` pass. The mandated legacy + `eval_chain.py` attempt fails before evaluation because that loader does not + add an external multi-file bot to `sys.path` (`ModuleNotFoundError: + symmetry`); it is non-authoritative. +- Rejected this cycle: sequential responder retargeting (77-91 parent, + kills 70-81); three healer-suppression placements (targets detected but + zero builds); bounded countertrade without the reserve exception (plans but + zero builds). Do not stack or revive them without new replay evidence. +- Root sources and `bot.zip` remain frozen v88. No upload, queue, activation, + or root replacement was performed. A remote inactive test requires fresh + user approval. + +## LIVE SUBMISSION BASELINE (`bots/generalist-v2`, Erebus v89) + +- The user uploaded/activated v89. Downloaded submission + `C:\Users\subodh\Downloads\fcode-gate-artifacts\v89-submission.zip` has the + same four uncompressed files as `bots/generalist-v2` / `generalist-v2.zip`. + First observed 40 live games: **26-14** (Ijti 15-10, lastpopperian_ 3-2, + Oogway 3-2, Atlas 5-0). Replays: + `C:\Users\subodh\Downloads\fcode-gate-artifacts\v89-live-replays`. +- v89 = frozen v88 + one exact nearby responder + surgical Sieger-2 + counter-battery. Fresh dev29 controls: **74% champion / 89% lastpop2 / + 85% OogwayOld**. +- Historical v88 remains `bots/core-sniper-v1` and the unchanged root + `main.py`/modules/`bot.zip`; its old controls were 77%/79%/85%. +- Both WSL environments run `fcode 2.3.2.dev29`; the active map pool is + synced. `bots/champion` remains hash-checked through `live_baseline.json`. + ## ⭐ OOGEREBUS — FIRST GATE-PASSED IMPROVEMENT OF OOGWAY'S BOT (2026-07-24 evening, repo 99534e2, bots/oogerebus, GATEKEPT — not for ladder) - **56% vs stock oogwip (PROMOTE, 32-29 kills), 62% vs krb (no regression).** = stock oogwip + passive siege-tile wall + dev26 crash armor + finisher march + heal-flee fix + print removal + vision guards. - **THE integration lesson (4th attempt won): the wall must be a PASSIVE — a spare-action side effect when a deny tile happens to be ortho-adjacent — never a state.** State-based walls (oogwip3/4: 32%/46%) starve his dual-purpose econ/defense crew. Passive = zero travel, zero priority competition. Wall set cached once (`_wallSet`): enemy-side ring+ties, cardinal rays d2-3, 2-step diagonals; core-facing conveyors, never-a-dead-end chaining rule. diff --git a/WORKFLOW.md b/WORKFLOW.md index 62f116d..62762ec 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -7,11 +7,13 @@ promotions by data instead of vibes. ## Layout ``` -main.py, defend.py, ... <- the CURRENT LADDER BOT (what fcode submit ships) +main.py, ... <- historical root package; may differ from live bots/ - champion/ <- FROZEN copy of the ladder bot. Never edit directly. + champion/ <- frozen, hash-verified reference opponent + generalist-v2/ <- exact live-v89 source + generalist-v3/ <- next locally passing candidate / <- your experiment: a folder with main.py (+ any modules) -maps/ <- the 15 league maps (.map26) +maps/ <- the current 21-map league pool (.map26) gate.py <- the promotion gate gate_results.csv <- every gate game ever run, appended automatically fcode.toml <- points the fcode CLI at bots/ and maps/ @@ -21,12 +23,56 @@ Every strategy/experiment is its own folder under `bots/`. Don't edit someone else's bot folder — copy it, rename it, hack on your copy. Multiple strategies coexist; nothing gets clobbered by a merge. +## Required generalist iteration cycle + +Every non-trivial iteration or rebuild starts with a written plan before code +changes. The plan must identify the observed failure categories, the proposed +general mechanism behind each one, the independent experiments, and the +evidence required to accept or reject each experiment. + +1. Freeze the current proven bot and record its source/archive hashes. +2. Review losses across maps, sides, seeds, and unrelated opponent families. + Treat a losing replay as diagnostic evidence for a possible bot weakness, + not as a target to encode. +3. Reject hypotheses that depend on map names, map dimensions, opponent + identity, side order, or one favorable matchup. Prefer observable game state + such as threats, congestion, resource flow, reachable firing plans, damage, + and positional progress. +4. Build each hypothesis independently from the same frozen baseline. Do not + combine unproven features, because a winning component can hide a regression + from another component. +5. Add deterministic tests for the mechanism itself, including activation, + non-activation, state cleanup, death/reassignment, and boundary timing where + relevant. +6. Use graduated gates: + - compile/protocol/deterministic tests and a fast crash screen; + - full both-side head-to-head against the frozen parent; + - full both-side gates against multiple unrelated opponent families; + - focused replay/metric checks proving the intended mechanism actually + caused the improvement. +7. Accept a feature only when it passes its aggregate regression thresholds and + improves the relevant failure category across at least two unrelated + opponent families. A single-map or single-opponent gain is investigation + material, never promotion evidence. +8. Stack accepted features one at a time in a declared order. After every + addition, rerun all parent and opponent gates; remove any feature that fails + when combined. +9. Freeze and package the final passing stack separately. Keep root sources, + the active submission, and the previous frozen baseline unchanged until the + complete stack passes and the user explicitly approves the next external + action. +10. After an engine or server-map update, mark older totals historical and + rerun the relevant gates before promotion. + +Record rejected hypotheses and why they failed, so future work learns from +them without reviving them under a new name. + ## Making a change -1. Copy the current best as your starting point: - `cp -r bots/champion bots/mybot` (or copy the root bot files into `bots/mybot/`). +1. Copy the exact frozen parent named in `HANDOFF.md`: + `cp -r bots/generalist-v2 bots/mybot`. 2. Hack on `bots/mybot/`. -3. Sanity-run one game: `fcode run mybot champion maps/duel.map26 --seed 1` +3. Sanity-run one game: `fcode run mybot champion maps/duel.map26 --seed 1 --tle 10` 4. Run the gate: `python gate.py mybot` ## The gate rule @@ -35,8 +81,8 @@ coexist; nothing gets clobbered by a merge. python gate.py ``` -runs the candidate vs the frozen `bots/champion` on all 15 maps x 2 seeds x -both sides = **60 games**, in parallel, and prints per-map results plus a +runs the candidate vs the frozen `bots/champion` on all maps x 4 seeds x +both sides, in parallel, and prints per-map results plus a verdict: - **PROMOTE** — win rate >= 55% @@ -47,31 +93,34 @@ variance). Over 60 games, ~50% is indistinguishable from "changed nothing". 55%+ is a real edge. If your change "should" help but gates at 48-54%, it's not proven — iterate or drop it, don't ship it. -Quick screen while iterating (24 games, 6 maps): `python gate.py mybot champion 1 fast` +Quick screen while iterating (12 games, 6 maps): `python gate.py mybot champion 1 fast` Head-to-head between any two bots: `python gate.py botA botB` **Measurement standard — results only compare if we all run the same setup:** - same engine (`pip show fcode` before trusting any number; update command is pinned in the Discord staging announcements) +- current server maps (`fcode maps list`, then `fcode maps sync` when anything + is missing or outdated) - `--tle 10` (the server's real per-turn budget — gate.py does this; ad-hoc `fcode run` tests must pass it explicitly, the default is tighter and punishes compute-heavy bots unrealistically) -- the full `maps/` pool, both sides, 2+ seeds (84+ games) +- the full `maps/` pool, both sides, 4+ seeds (168+ games) Two of us measured the same matchup 26 points apart once (kfort vs krb, 72% vs 46%) — environment drift, not variance. Check setup first, argue strategy second. ## Promotion procedure -1. `python gate.py mybot` says PROMOTE. -2. Copy `bots/mybot/*` over the root bot files, submit to the ladder. -3. Watch real matches. **Live results override the gate** — a lab PROMOTE that - loses rating on the ladder gets reverted, no argument. -4. Once it holds up live, freeze it: copy the new bot over `bots/champion/` - (same commit, so champion always == what's proven on the ladder). +1. Pass the frozen-parent and all broad gates in the required cycle. +2. Freeze source, results, and a byte-verified ZIP under a new version. +3. With fresh user approval, upload it **inactive** and run the declared + unranked replay sample. +4. If the live threshold passes, request separate approval before activation + or root replacement. Otherwise leave the current submission/root unchanged. -The champion only ever moves forward through steps 1-4. Nobody edits -`bots/champion/` directly. +`bots/champion/` is a stable reference opponent, not automatically the active +submission. Nobody edits it casually. `live_baseline.json` records its exact +hashes, and `gate.py` refuses to use a drifted default champion. ## War Room dashboard diff --git a/bots/champion/coreHelper.py b/bots/champion/coreHelper.py deleted file mode 100644 index b095a30..0000000 --- a/bots/champion/coreHelper.py +++ /dev/null @@ -1,32 +0,0 @@ -from fcode import * - - -def spawnBots(numSpawned: int, ct: Controller) -> bool: - currentTitanium = ct.get_global_resources() - if numSpawned < 5 and currentTitanium >= 350: - return True - if numSpawned > 4 and currentTitanium >= 400 * ct.get_scale_percent() / 100: - return True - return False - - -def findThreats(ct: Controller) -> list: - threats = [] - curRound = ct.get_current_round() - for i in ct.get_nearby_buildings(8): - if (ct.get_entity_type(i) == EntityType.LAUNCHER and - ct.get_team(i) != ct.get_team()): - threats.append(ct.get_position(i)) - return threats - - -def findTeamCore(ct: Controller): - for i in ct.get_nearby_buildings(): - if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): - return ct.get_position(i) - - -def findEnemyCore(ct: Controller): - for i in ct.get_nearby_buildings(): - if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) != ct.get_team(): - return ct.get_position(i) diff --git a/bots/champion/defend.py b/bots/champion/defend.py deleted file mode 100644 index 0df8efc..0000000 --- a/bots/champion/defend.py +++ /dev/null @@ -1,119 +0,0 @@ -from fcode import * -import random - - -class Defender: - def __init__(self): - self.defendPos = None - self.destroyTurns = 0 - - def defendInfrastructure(self, ct: Controller, teamCore, pf): - myLoc = ct.get_position() - if self.defendPos is None: - for i in ct.get_nearby_buildings(): - if ct.get_team(i) == ct.get_team() and ct.get_hp(i) < ct.get_max_hp(i): - self.defendPos = ct.get_position(i) - break - if self.defendPos is not None: - if myLoc.distance_squared(self.defendPos) > 2: - pf.moveTo(ct, self.defendPos) - self.placeLauncher(ct, self.defendPos) - if ct.can_heal(self.defendPos): - ct.heal(self.defendPos) - - if self.defendPos is not None: - if ct.is_in_vision(self.defendPos): - defendID = ct.get_tile_building_id(self.defendPos) - if defendID is None: - self.defendPos = None - elif ct.get_hp(defendID) == ct.get_max_hp(defendID): - self.defendPos = None - else: - self.defendPos = None - - def placeLauncher(self, ct: Controller, pos: Position): - myLoc = ct.get_position() - for i in ct.get_nearby_buildings(): - if ct.get_entity_type(i) == EntityType.LAUNCHER and ct.get_team(i) == ct.get_team(): - if ct.get_position(i).distance_squared(pos) <= 2: - return # Launcher already built - topCands = [] - otherCands = [] - for k in ct.get_nearby_tiles(2): - if k.distance_squared(pos) <= 2 and k.distance_squared(myLoc) <= 2: - bid = ct.get_tile_building_id(k) - if bid is None and ct.get_tile_env(k) != Environment.WALL: - topCands.append(k) - elif bid is not None and ct.get_team(bid) == ct.get_team(): - etype_k = ct.get_entity_type(bid) - if etype_k not in (EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SENTINEL): - otherCands.append(k) - for a in topCands: - if ct.can_build_launcher(a): - if self.destroyTurns > 5: - self.destroyTurns = 0 - ct.build_launcher(a) - return - didDestroy = False - for a in otherCands: - if ct.can_destroy(a) and didDestroy == False: - ct.destroy(a) - didDestroy = True - if ct.can_build_launcher(a): - if self.destroyTurns > 5: - self.destroyTurns = 0 - ct.build_launcher(a) - return - return - - def picHealingLoc(self, ct: Controller, teamCore: Position, alwaysDefense: bool = False) -> Position | None: - candidates = [] - for i in ct.get_nearby_tiles(16): - if ct.get_tile_env(i) == Environment.EMPTY and i.distance_squared(teamCore) < ((ct.get_map_width() + ct.get_map_height() - 8) // 4): - bid = ct.get_tile_building_id(i) - if bid is None: - candidates.append(i) - else: - entype = ct.get_entity_type(bid) - if (entype == EntityType.CONVEYOR or entype == EntityType.CORE): - candidates.append(i) - - if not candidates: - return Position(teamCore.x + random.randint(-1, 2), teamCore.y + random.randint(-1, 2)) - - buildingweights = [] - buildingpos = [] - for bid in ct.get_nearby_buildings(): - if ct.get_team(bid) == ct.get_team(): - entype = ct.get_entity_type(bid) - if entype == EntityType.CONVEYOR: - buildingweights.append(25) - buildingpos.append(ct.get_position(bid)) - elif entype == EntityType.LAUNCHER: - buildingweights.append(5) - buildingpos.append(ct.get_position(bid)) - elif entype == EntityType.CORE: - buildingweights.append(100) - buildingpos.append(ct.get_position(bid)) - - bestTile = None - bestScore = -1 - - for i in candidates: - score = 0 - for j in range(len(buildingweights)): - pose = buildingpos[j] - weight = buildingweights[j] - dist = i.distance_squared(pose) - if dist < 50: - score += max(weight * (1 - 0.02 * dist), 0) - - if score > bestScore: - bestTile = i - bestScore = score - if alwaysDefense == True: - return Position(teamCore.x + random.randint(-1, 1), teamCore.y + random.randint(-1, 1)) - else: - if bestTile is None: - return Position(teamCore.x + random.randint(-1, 1), teamCore.y + random.randint(-1, 1)) - return Position(bestTile.x + random.randint(-1, 1), bestTile.y + random.randint(-1, 1)) diff --git a/bots/champion/initialSpawning.py b/bots/champion/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/champion/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/champion/main.py b/bots/champion/main.py new file mode 100644 index 0000000..6e01356 --- /dev/null +++ b/bots/champion/main.py @@ -0,0 +1,563 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the first-spawned builder walls the enemy-side ring + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # picked by SPAWN ORDER via store slot 0 — entity ids interleave + # across teams, so an id threshold silently matched nobody as + # player B (t55 losses unchanged until this was fixed). + if self.turnsAlive == 1: + self.mySpawnIdx = ct.read_store(0) # count at my birth = my index + if (getattr(self, "mySpawnIdx", 99) <= 1 + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = Position(self.mapW - 1 - cx, self.mapH - 1 - cy) + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = Position(self.mapW - 1 - cx, self.mapH - 1 - cy) + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and ct.get_id() > 4: # dont attack if u r broke and leave one bot for defense + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and ct.get_id() > 4): + tc = self.mapPf.teamCore + explorePos = Position(self.mapW - 1 - tc.x, self.mapH - 1 - tc.y) + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) \ No newline at end of file diff --git a/bots/champion/mapPathfinding.py b/bots/champion/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/champion/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/champion/pathfinding.py b/bots/champion/pathfinding.py deleted file mode 100644 index 9ba6647..0000000 --- a/bots/champion/pathfinding.py +++ /dev/null @@ -1,156 +0,0 @@ -from fcode import * - -DIRECTIONS = [ - Direction.NORTH, - Direction.EAST, - Direction.SOUTH, - Direction.WEST, -] - - -class Pathfinder: - def __init__(self): - self.minDistToTarget = 2**10 - self.objectOnRight = True - self.currentObstacle = None - self.currentTarget = None - self.canRotate = True - self.visitedStates = set() - - def reset(self): - self.currentTarget = None - self.minDistToTarget = 2**10 - self.objectOnRight = True - self.currentObstacle = None - self.canRotate = True - self.visitedStates = set() - - def isInBounds(self, c: Controller, pos: Position) -> bool: - W = c.get_map_width() - 1 - H = c.get_map_height() - 1 - return pos.x == max(0, min(W, pos.x)) and pos.y == max(0, min(H, pos.y)) - - def canMove(self, c: Controller, direction: Direction) -> bool: - dest = c.get_position().add(direction) - if not self.isInBounds(c, dest): - return False - return c.can_move(direction) - - def _move(self, c: Controller, direction: Direction): - if c.can_move(direction): - c.move(direction) - - @staticmethod - def _diagonalDist(a: Position, b: Position) -> int: - return abs(a.x - b.x) + abs(a.y - b.y) # cardinal update - - def _isKnownPassable(self, c: Controller, pos: Position) -> bool: - # Only trust this if we currently have vision on the tile -- otherwise we can't - # tell whether the remembered obstacle has actually cleared. - if not self.isInBounds(c, pos) or not c.is_in_vision(pos): - return False - if c.get_tile_env(pos) == Environment.WALL: - return False - return c.get_tile_building_id(pos) is None - - def _setInitialDirection(self, c: Controller): - myLoc = c.get_position() - forward = myLoc.direction_to(self.currentTarget) - left = forward.rotate_left() - right = forward.rotate_right() - - for _ in range(8): - if self.canMove(c, left): break - left = left.rotate_left() - for _ in range(8): - if self.canMove(c, right): break - right = right.rotate_right() - - leftLoc = myLoc.add(left) - rightLoc = myLoc.add(right) - leftDist = self._diagonalDist(leftLoc, self.currentTarget) - rightDist = self._diagonalDist(rightLoc, self.currentTarget) - - if leftDist < rightDist: - self.objectOnRight = True - elif rightDist < leftDist: - self.objectOnRight = False - else: - # Tie on Chebyshev distance -- break it with actual distance from the bot. - self.objectOnRight = myLoc.distance_squared(leftLoc) < myLoc.distance_squared(rightLoc) - - self.currentObstacle = (myLoc.add(left.rotate_right()) - if self.objectOnRight - else myLoc.add(right.rotate_left())) - - def _followWall(self, c: Controller, myLoc: Position, canRotate: bool = None): - if canRotate is None: - canRotate = self.canRotate - direction = myLoc.direction_to(self.currentObstacle) - for _ in range(8): - direction = (direction.rotate_left() if self.objectOnRight else direction.rotate_right()) - if self.canMove(c, direction): - self._move(c, direction) - return - loc = myLoc.add(direction) - if not self.isInBounds(c, loc): - if canRotate: - # Hit the map edge -- flip which side we're hugging and restart the - # sweep from scratch (rather than continuing this loop with a direction - # that points off the map), locking further flips until the next reset. - self.objectOnRight = not self.objectOnRight - self.canRotate = False - self._followWall(c, myLoc, canRotate=False) - return - continue - if not self.canMove(c, direction): - self.currentObstacle = loc - - def _state(self, c: Controller, target: Position): - myLoc = c.get_position() - ref = self.currentObstacle if self.currentObstacle is not None else target - direction = myLoc.direction_to(ref) - return (myLoc.x, myLoc.y, direction, self.objectOnRight) - - def moveTo(self, c: Controller, target: Position): - if self.currentTarget is None or self.currentTarget != target: - self.reset() - self.currentTarget = target - - if not any(self.canMove(c, d) for d in DIRECTIONS): - return - - myLoc = c.get_position() - - dist = self._diagonalDist(myLoc, target) - if dist < self.minDistToTarget: - self.reset() - self.minDistToTarget = dist - - # If the obstacle we're routing around has turned out to be passable (vision - # updated, a unit moved off it, etc.), stop treating it as blocking and retry - # a direct line instead of continuing to wall-follow around nothing. - if self.currentObstacle is not None and self._isKnownPassable(c, self.currentObstacle): - self.reset() - self.minDistToTarget = dist - - # Cycle detection: if we've already been in this exact (position, facing-toward- - # obstacle-or-target, which-side-we're-hugging) state on this trip, the wall-follow - # sweep is looping around an obstacle it can't escape -- force a reset instead of - # repeating the same loop forever. - state = self._state(c, target) - if state in self.visitedStates: - self.reset() - self.minDistToTarget = dist - else: - self.visitedStates.add(state) - - self.currentTarget = target - - if self.currentObstacle is None: - forward = myLoc.direction_to(target) - if self.canMove(c, forward): - self._move(c, forward) - return - self._setInitialDirection(c) - self._followWall(c, myLoc) diff --git a/bots/generalist-v2/initialSpawning.py b/bots/generalist-v2/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/generalist-v2/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/generalist-v2/main.py b/bots/generalist-v2/main.py new file mode 100644 index 0000000..dd6d6f5 --- /dev/null +++ b/bots/generalist-v2/main.py @@ -0,0 +1,1093 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/generalist-v2/mapPathfinding.py b/bots/generalist-v2/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/generalist-v2/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/generalist-v2/symmetry.py b/bots/generalist-v2/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/generalist-v2/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/generalist-v3/README.md b/bots/generalist-v3/README.md new file mode 100644 index 0000000..a8cd379 --- /dev/null +++ b/bots/generalist-v3/README.md @@ -0,0 +1,25 @@ +# generalist-v3 + +Live-v89 source plus one bounded emergency countertrade: + +- activates only with at least two visible turrets whose real attack patterns + hit the core; +- keeps the responder's builder stand outside every visible attack pattern; +- permits a countergun seat covered by at most one turret; +- waives the 120-Ti reserve only for that one trade. + +No opening, economy, spawning, siege, role, store, map, or opponent-specific +logic changed. + +Dev29 gates (168 games each): + +| opponent | wins | core kills | +|---|---:|---:| +| generalist-v2 | 90 (54%) | 79-69 | +| champion | 135 (80%) | 120-21 | +| lastpop2 | 153 (91%) | 147-14 | +| OogwayOld | 146 (87%) | 143-15 | + +Mechanism trace: 4 completed countergun builds from 20 plans against champion. +Second-family builds were not reproduced locally, so inactive live validation +is still required. See `generalist_v3_results.json` and `HANDOFF.md`. diff --git a/bots/generalist-v3/initialSpawning.py b/bots/generalist-v3/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/generalist-v3/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/generalist-v3/main.py b/bots/generalist-v3/main.py new file mode 100644 index 0000000..29d6af8 --- /dev/null +++ b/bots/generalist-v3/main.py @@ -0,0 +1,1175 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/generalist-v3/mapPathfinding.py b/bots/generalist-v3/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/generalist-v3/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/generalist-v3/symmetry.py b/bots/generalist-v3/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/generalist-v3/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/gate.py b/gate.py index d6979cc..e00bf8f 100644 --- a/gate.py +++ b/gate.py @@ -2,17 +2,20 @@ Runs a candidate bot against the frozen champion baseline across every map, multiple seeds, both sides, in parallel. Prints per-map results, appends every -game to gate_results.csv, and gives a verdict: +game and its current-engine JSON economy metrics to gate_results.csv, and gives a +verdict: PROMOTE win rate >= 55% (a clear edge; anything nearer 50% is noise) REJECT otherwise Usage: - python gate.py [opponent=champion] [seeds=2] [fast] + python gate.py [opponent=champion] [seeds=4] [fast] [quiet] + [maps=name1,name2] - python gate.py mybot # full gate: 15 maps x 2 seeds x both sides = 60 games - python gate.py mybot champion 1 fast # quick 6-map screen (24 games) - python gate.py mybot oldbot 3 # any bot vs any bot, 3 seeds (90 games) + python gate.py mybot # full gate: all maps x 4 seeds x both sides + python gate.py mybot champion 1 fast # quick 6-map screen (12 games) + python gate.py mybot oldbot 4 # any bot vs any bot, 4 seeds + python gate.py mybot oldbot 16 maps=string,bridge quiet Bots live in bots// with a main.py entry point. See WORKFLOW.md. Draws and crashed/unparseable games count AGAINST the candidate. @@ -20,22 +23,32 @@ import concurrent.futures as cf import csv import datetime as dt +import hashlib +import json import os -import re import shutil import sys +import tempfile from pathlib import Path from subprocess import run ROOT = Path(__file__).parent -args = [a for a in sys.argv[1:] if a != "fast"] +MAP_ARG = next( + (a.split("=", 1)[1] for a in sys.argv[1:] if a.startswith("maps=")), + None, +) +args = [ + a for a in sys.argv[1:] + if a not in ("fast", "quiet") and not a.startswith("maps=") +] FAST = "fast" in sys.argv[1:] +QUIET = "quiet" in sys.argv[1:] if not args: sys.exit(__doc__) CAND = args[0] BASE = args[1] if len(args) > 1 else "champion" -SEEDS = int(args[2]) if len(args) > 2 else 2 +SEEDS = int(args[2]) if len(args) > 2 else 4 if CAND == BASE: sys.exit("candidate and opponent are the same bot name - the winner line can't be " @@ -46,39 +59,173 @@ if not (ROOT / "bots" / bot / "main.py").is_file(): sys.exit(f"no bot at bots/{bot}/main.py - see WORKFLOW.md for the layout") + +def file_sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest().upper() + + +if BASE == "champion": + manifest_path = ROOT / "live_baseline.json" + if not manifest_path.is_file(): + sys.exit("missing live_baseline.json; refusing to gate against an unverified champion") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + expected_files = manifest.get("files", {}) + champion_dir = ROOT / "bots" / "champion" + actual_files = { + p.name for p in champion_dir.iterdir() + if p.is_file() and p.suffix == ".py" + } + mismatches = [] + if actual_files != set(expected_files): + mismatches.append( + f"file set is {sorted(actual_files)}, expected {sorted(expected_files)}") + for name, expected in expected_files.items(): + path = champion_dir / name + if path.is_file(): + actual = file_sha256(path) + if actual != expected.upper(): + mismatches.append(f"{name}: {actual} != {expected.upper()}") + if mismatches: + sys.exit("champion does not match the recorded live baseline:\n " + + "\n ".join(mismatches)) + + +version_result = run( + ["fcode", "--version"], capture_output=True, + encoding="utf-8", errors="replace", cwd=ROOT) +ENGINE = (version_result.stdout or version_result.stderr or "unknown").strip() + ALL = sorted(p.stem for p in (ROOT / "maps").glob("*.map26")) if not ALL: sys.exit("no maps in maps/") -MAPS = [m for m in ["duel", "quarry", "aurora", "twins", "longship", "sprint"] if m in ALL] if FAST else ALL -WIN = re.compile(r"Winner:\s+(\S+)\s+\((.*?),\s*turn\s*(\d+)\)") +if MAP_ARG: + requested_maps = [m.strip() for m in MAP_ARG.split(",") if m.strip()] + missing_maps = [m for m in requested_maps if m not in ALL] + if missing_maps: + sys.exit(f"unknown maps in maps=: {', '.join(missing_maps)}") + MAPS = requested_maps +elif FAST: + MAPS = [ + m for m in + ["duel", "quarry", "aurora", "twins", "longship", "sprint"] + if m in ALL + ] +else: + MAPS = ALL + +METRIC_COLUMNS = [ + "candidate_titanium", + "opponent_titanium", + "candidate_titanium_collected", + "opponent_titanium_collected", + "candidate_units", + "opponent_units", + "candidate_buildings", + "opponent_buildings", +] +CSV_COLUMNS = [ + "time", + "candidate", + "opponent", + "map", + "seed", + "first_player", + "winner", + "condition", + "turns", + "engine", +] + METRIC_COLUMNS def game(first, second, m, seed): - # utf-8 + replace, not text=True: on Windows the default cp1252 decode - # dies on any non-ascii byte in the output and returns stdout=None - out = run(["fcode", "run", first, second, f"maps/{m}.map26", - "--seed", str(seed), "--tle", "10"], - capture_output=True, encoding="utf-8", errors="replace", - cwd=ROOT).stdout or "" - mo = WIN.search(out) - if not mo: - return m, first, second, seed, "draw", "unknown", 0 - return m, first, second, seed, mo.group(1), mo.group(2), int(mo.group(3)) + # Every concurrent game gets a private disposable replay path. The CLI's + # default replay.replay26 path is shared and races under the gate's worker + # pool, which used to dirty the tracked fixture and corrupt replay output. + with tempfile.TemporaryDirectory(prefix="fcode-gate-") as temp_dir: + replay_path = Path(temp_dir) / "game.replay26" + out = run( + [ + "fcode", + "run", + first, + second, + f"maps/{m}.map26", + "--seed", + str(seed), + "--tle", + "10", + "--json", + "--replay", + str(replay_path), + ], + capture_output=True, + encoding="utf-8", + errors="replace", + cwd=ROOT, + ).stdout or "" + result = None + for line in reversed(out.splitlines()): + line = line.strip() + if not line.startswith("{"): + continue + try: + result = json.loads(line) + break + except json.JSONDecodeError: + continue + if result is None: + return m, first, second, seed, "draw", "unknown", 0, { + column: 0 for column in METRIC_COLUMNS + } + + winner_side = result.get("winner") + winner = first if winner_side == "A" else second if winner_side == "B" else "draw" + candidate_is_a = first == CAND + candidate_prefix = "a" if candidate_is_a else "b" + opponent_prefix = "b" if candidate_is_a else "a" + metrics = { + "candidate_titanium": result.get(f"{candidate_prefix}_titanium", 0), + "opponent_titanium": result.get(f"{opponent_prefix}_titanium", 0), + "candidate_titanium_collected": result.get( + f"{candidate_prefix}_titanium_collected", 0), + "opponent_titanium_collected": result.get( + f"{opponent_prefix}_titanium_collected", 0), + "candidate_units": result.get(f"{candidate_prefix}_units", 0), + "opponent_units": result.get(f"{opponent_prefix}_units", 0), + "candidate_buildings": result.get(f"{candidate_prefix}_buildings", 0), + "opponent_buildings": result.get(f"{opponent_prefix}_buildings", 0), + } + return ( + m, + first, + second, + seed, + winner, + result.get("win_condition", "unknown"), + int(result.get("turns", 0)), + metrics, + ) jobs = [(a, b, m, s) for m in MAPS for s in range(1, SEEDS + 1) for a, b in ((CAND, BASE), (BASE, CAND))] n = len(jobs) -print(f"GATE {CAND} vs {BASE} | {len(MAPS)} maps x {SEEDS} seeds x both sides = {n} games\n") +print(f"GATE {CAND} vs {BASE} | {len(MAPS)} maps x {SEEDS} seeds x both sides = {n} games") +print(f"ENGINE {ENGINE}\n") rows, w, kills, kills_against, oddballs = [], 0, 0, 0, 0 # 6, not 12: heavy bots (khaos's map analysis) at 12 parallel engines exhaust # the Windows commit limit -> WinError 1455 + phantom "draw (unknown)" games with cf.ThreadPoolExecutor(max_workers=min(6, os.cpu_count() or 4)) as ex: - for i, (m, first, second, seed, winner, cond, turn) in enumerate( + for i, (m, first, second, seed, winner, cond, turn, metrics) in enumerate( ex.map(lambda j: game(*j), jobs), 1): rows.append([dt.datetime.now().isoformat(timespec="seconds"), - CAND, BASE, m, seed, first, winner, cond, turn]) + CAND, BASE, m, seed, first, winner, cond, turn, ENGINE] + + [metrics[column] for column in METRIC_COLUMNS]) if winner == CAND: w += 1 if "destroyed" in cond.lower(): @@ -88,14 +235,28 @@ def game(first, second, m, seed): kills_against += 1 if winner not in (CAND, BASE): oddballs += 1 - print(f" [{i:>2}/{n}] {m:12s} seed {seed} {first} first -> {winner} ({cond}, t{turn})") + if not QUIET: + print(f" [{i:>2}/{n}] {m:12s} seed {seed} {first} first -> {winner} ({cond}, t{turn})") -new = not (ROOT / "gate_results.csv").exists() -with open(ROOT / "gate_results.csv", "a", newline="", encoding="utf-8") as f: +results_path = ROOT / "gate_results.csv" +new = not results_path.exists() +if not new: + with open(results_path, newline="", encoding="utf-8") as f: + existing = list(csv.reader(f)) + if existing and existing[0] != CSV_COLUMNS: + old_header = existing[0] + migrated = [CSV_COLUMNS] + for old_row in existing[1:]: + values = dict(zip(old_header, old_row)) + migrated.append([values.get(column, "") for column in CSV_COLUMNS]) + tmp_path = results_path.with_suffix(".csv.tmp") + with open(tmp_path, "w", newline="", encoding="utf-8") as f: + csv.writer(f).writerows(migrated) + os.replace(tmp_path, results_path) +with open(results_path, "a", newline="", encoding="utf-8") as f: cw = csv.writer(f) if new: - cw.writerow(["time", "candidate", "opponent", "map", "seed", - "first_player", "winner", "condition", "turns"]) + cw.writerow(CSV_COLUMNS) cw.writerows(rows) pct = 100 * w / n diff --git a/generalist-v3.zip b/generalist-v3.zip new file mode 100644 index 0000000000000000000000000000000000000000..b7519fbf48cdc5d0fbc9833f1492ede3f1521ce8 GIT binary patch literal 17993 zcmV)MK)An9O9KQH000080Jn_$TuJA{_gn=40L>Es01^NI0BLS%bZKF1Q*dE-ZfS03 zE^v9pSZz<^I1v7h#DB1KU)m)tNlSr63lht60;#;HbhT7fRfWV*@1}O-xIq8?jqSWR zj$3Z;q`RyZn(@pt<9T`Pvb#f;DdPeWfw^ET$#!?1Ia{LVh{YJ?ix-Rv&XNRk4}BvX zM}jcwp-U==SdZ5!mY-hMolmq%Z9S!(qTQ>NF5uhV`Ft}u#SpLg25gkn<0Hk@IB^`<|>gv zf8e3OKZYNNfcV+>wEO_XM<57brI7Ul5cZBi(m#Nu-chm8>&b@(yeoMhM<_R5-OwzZ-UWj;Y)n&yg3khuX* z#~40kt@sggt3FKHGLwap4(9X}`Nr1e!?9R#il!=z8TtfJb*srXE46iH^=NyWz}DkO z%P!?)8S*t4L6vaCXsq8LdKG}Dlj$_NU@OVLrI~sM!+7E;_hmUCU4P%L^5<#qzvot^ z-P|=@Ew3rGC7d=C6ij=xzi%N_W~sLLny~1Xq>U>nHYuZY{RVbaQzT5jx$(-Ul)v1%rkR9 zp_vj)Tcu(fhGJk9SW&$q-C2F%nQ`d?xTNxA*;e-5FikOyrK@Q$Yd4X|M>vGW_+5@- zb@X(VtVNoL@=;_01|=ODNtkLhXq|%@P}3eV&KJD~;#%5j7HhY~+7X3jH#3Z!1p%Ud zBQgBuJh)NC*8})XUJNL1gC5 zM)YU@s9kfF46eAXwoPm-UPZFp!NwM+FSsSAPQ$d}V&Mq20xCWMgJ_E>*$ZZ!;`6M% z-}g5KVc7N$eIT9OsuRqhxAGU?0bypU0I6#Lb(BhHRhKxnVs=nr;?^n~%5K6kdzF^u zXbU@7CHntq;Xk2-e?td36;#Qtj-3n(dFrU?b{;!sGcVUe7RjoME_j=##dZx>VhHO> z&ybA@SvQR1R{id0*gg$6=&5U*skjMHD{l8<6k8IH7((_&9$>=%p+O+aF#S>O72f7= zB!+u*yQXqgS6N{4vZ%30NsJC&H3%d)v{?fT@AW-JmZfW6nE0ig5Lq$b$ZKoRL6UKKfE( zmBWo{h{j~aOcaCF8w{!+jtKH70E1+JFn=XkNdT^Mpm;EStvW}L1kXW|n{BoNPs;Gr z{5wncZge>xUysjkufN}HaVej&CU6b}hg-r++^#Nf?#Gkg@v+TC%GZexiWdgl{Xo2v1nlMi2OVde71jSus zT1Y(el!Hl{HK|)sKt?4O9KQH000080Q9^4Tq~A@*@Gwm05iz|00#g7 z0BvDuZZ2?n?R{x)8^^NfcLV+p9UH!vOh}rdmN-gmK(Xm#0g7x$bb`PTh#@%==a`&f zW`?GC?mG|fN4#Hnf61+?UZ&UKNU_feE<8wVvzO|vy7umV^ytxL8Rcc1vq@I6E{j-^ zELV$|#kUdtU1n?+FSE2L^Qes3x`=LKmR+-Q9BHBWs1fWFeH2#rl$LURy#%vlb76r@KX{*gL$Yd)hnP=;C1_0j| zdMiBuI*!vaDYxTtyNaO(bZ?Ra%sJF$n|V~Sk4ZYi4pJ|+*_uUp43Hi@di3zY!v`?9 zEH4>UnPp2%xX!aByPjsVn2Efqy$*Yo<}!&Em#b)#n%Pl$vxwitIl#pZJ$%66+uJ|vu)Y2>_~U5MVL$Xc zJpBNYzlVf9$dPILdyqJI1}XaokTv*TW()?Hafro#KuFK}4iRD*H} zTHU=4>+ZFyMQQcz1T50`m3Yj`GYi~qXf`nM8(>|yuxwDZkVO7tHnAafcY$(-$P*CO z8BO9$V!M=;PM1H`~d!7>ATc8>fr>A-WqIna)RQwcdXEE|;FucN?J+Mv2N ztga2Ldt}b#%{oov{G=>lfBUA=UYx$$x&3y>t>hw}GuLiCcjX6m!agOq$l14M)cEbK zr>|f!pYf#2)8XGBX^$uVU?dKB;$EMp4$p_b{62p9 z_F{;MdsfkxZ{MGf;9sMYi@yy=SYn@-c=Ps`;rP|?_3-?aYR)t zr5)%fh)nTp4075GrWyMk8j)5Id#fO^7tDuBqA$}dkH^tq3tj#-+eT@IoH+lUM92q;b&7s1)-s~!N5GnfKFt(xDtotH_f%~;iKmG&{-K3_!3 z$t(gjB3>SA6O7*;wuu=Z`d7GU)o57^D+BVSQJhNdUG?Nh#>F4&2nhk7nYO2N|Efw9 z`E5vq>7Op5q7X_AA1E%!c#Pa+JZ=^7;<|nOP@@uy>mCZ&*FbcCsRV6UueMbRArR++ zlDg3C6s$NLlIehbY8Izk#>r_!x*!7zMLPkv@^ZBi zmV2l$4wPo>E+HJu3+(SocO?>sjChrrfbDqg&iZ$V7Q@YD~;idNf9xuR}5EpaWACd~-f zGs|7o_NP~5dD+s$ex80T{&q!ohmqRED~zn7Nnq<0!fI(YNtWLPs#cjTGMHkev0#wBPP`EY7jj{6>HViu!KlZQ<9t1))7lkA+d}QAsdU2D3*U3 z938_T**cv@>zjGW;@fGwBF*{T@Zy(~^WpjE>~|Q6%^@bPy?F7{oDKV z^WjBJKmSIScM~EdpLX;ojrj;Qn}ty|tbpjB=lyt{fi6b6DZlZ@5con#R}MQXkJ zC0fz8H1fym6`DlGq?VG6%P1p}SySXCN+aLIpN4UpI8UV=#kZ?PW~)J{T1V5;>H7E9 znzOytPMN#T+%bH8%y4YY3?o6Y{)cJy}J-X;PN z%plsaY6!y^5Krq>fs4}N=poH)?w;p1FO6WKjLKfUAN_tzOM;8bR_n_B-!c!g|vS?jq zt72=Acx)SY39bs-Wq{bm02R%lKvARX>tsr%Le=jM@jRgaF>t1?4yst;5}@A&y8j8O zw-^p+@)7zSc|1S~)E}w+KjJ*2COaVSWia}9r9wt~Sbm4hF8>@6^dSxgoN*lZFq zBKiM4=*!7~^%UlA9aIbZ4h?mudA#AHyd0g3hG>k>KLIofPne?Jcz^JO-9QWN9(%Qh znNJEG&?RCQ6ZZK+WBy{Hy2*@iqL^ok8JeiQPQ}u|?3d={nERpiWcQYNP4-sPC<$k*_k2 zmdG#kon!6M$L#fcz;=c5wAeVFWgHc25adu=fRW>Xra-)|*RUQlob2c9+2NCi7O+oz zGB$ug0HKT+DD?+VK&Q>~EdLSG%jtYcmdP^9*aCxAqQxfK7C+jMmdP~Fx}qh5S0pI7 z#DK4@@@T+5CW}P@D?geO9uawfwrw~k=cCi^#rw-Q?DF04<>|@U>E-B0ihU^(*wU~y zv+O3$HYFnf78)!%QI(CZ@ z4JR)TRnTV2C~HlU6g$xFd07Hp`RYAp?@lf+Pk$Y9d?!lt@fw3=K))-Hq9i~oZgL`8 zn3DO5@uSBcdX* zpPR52K&VM>rre@T(unMNh?|>awg2=02sH*lp&(t9v~6&!bBbtl2qcc#a?4skADZZfOCMc7)xXP0(M+OJvZAvj`Yi@P4BnQEuenV$0 zE`qBGRvL~+jH*|7965wjI{5SDJW6jsqxT@5#s` zUpl_V#r)uh9Fv#`AQ>8x9Mgo%MDv7Aaik88a9bLbHb$@Mc`oInkVi9;nJ1&KlxCqO7m zQkqA-Gi}dK4^%za7~D)$K~lJyAZPs!NQcou!H^qvFitU;wX(Y#aIH>hWs}r$uzNj(KdGQO;sHBJnnH8VlJXvXxhH|JRB~_ z;GRF1z44f7VJ?!?54^;164nZ|JAD;Ov$-XYCD5yJd z&+BN4;;E{koSfk*JbXkgyQs)hQ8VT_V+=ia<(`#_>EbPS%$afKt-`QgLTb9`o!$x0 z{0L0yMXMET$`(33sz@&F-`kFkTUAEe3~WQwv={dcpm-!cC!Rjb;lSrr%Qa~MQL}J>W38KKy=j^{^L@2r0;omeps*QiTJ_ZYc1Yb4aT6k9lnnHWdfP}1w z{%$7^J0O_3?6xB}@lojNJf3th4y@pLmXp6l|1t?j)4gfrRdql=Y5 z;@O}ojwlEfKkR^3ajR8)v$G;BUQ)oM(Y8S*f-r1P+Fi!MC*&IuKOTmok$8pEc%m&M z3lr}~eegx&UWw)euEKDsMf4aUaTRU-3HTplv_^-~KAIFQ9!-x&z{K*R4Xw)9s*G(w zp2LsOve|hhr`qj~{B1bjAQRzHnmf>2F1U?|8Vdmq$sJDVPm~Ixdw6LC5yucw>+~e= zvmrFIc(NMrx=bQm0_5OJOs(}?x9&`RI5~)$emQFj&qzk4i z6;G{M`4}p`nAvvWF^u71_VM$Zl-W8kzU2WG}w|6_WjRo`49m z*p7K*IlmgFXd)dr<|#Ke2M@B%gaTQF3So1hqjI(^Kb`piD-CgSRxR*bVN@-xBQUrEieIvnhc>;&o zQnmeu6|FLXS*<3NN0M0DDBTH^W9Y5CMTpLps;hMNt+uhw)~d7B>TErA@YD1;D_+np zDVWutiEZGk8DLt5xM2tyl`vt55%+As7A{tzGD z)`CV9mD;a@qi;SeU~M|AV(YUj`$8l9xDeq;1}jkKSENjXSkZO}i40H&{x!-d9v)i+ zhO7GnonveF(1QJp&k35hvC6A7@I>K8|KOt?US%*SB-LP*x6YMK)o4SfTR$5T>@qQ8 zue~L;fC*yHs%FEGcpf95T1&Nw3dOw#7*60+3x4kL9}XxW5+Nk-BHp8Ut7$aIC}&>4 zgIIIG(!9&gzK{vY62kYgLQ`#b9Ev`BuYHKl=zK$a6MoKs91D_Z3bh6)$K>E4iiY}t zQQpHsHMa^insC@_x+T!79Z0r(HPjJ$O2c2=DtE~-PLLYAfDsh+c=CL__#&fQm%#j6M9xVWs&V}gF9 zW3g;dovNklAuu|Lju1=y78iM{8wl}JHz)BCxCdR#cuMoo{4Oe0kqB|AD0P=#EozV` z0_zE2(vLe#xovdd+e7hfJYAQu+l<>vhxe>L$WR7kxin7Y7?dp#|04@)6w{K^WeMb3 z3bZ-GKkfdIa6jx{b*dkS`j3MvOGYx|Z4AG|10QTTLgnO;89}E7F)oQDrZ7YJinGHJ zSHI*{!w^jTuy-XLRbmN{4pGj)%2Cy?3^Y@{*c}xYxRKZicH4!LwY*K>Xw|P>R2T=@ z?8;u3{C=?^-f!25UWa#y>qxNi&l(~E#K#u`j*Pt~_X_Ku@J-mx599vgpjX7>PvqV6 znT0r|u5}r9WqrSeMs05WQdVz)y}!ldQ`qW!EvAR9b|* zGt`y@RAc^@AKfeHW6%QHPHU(Oz^HterLubsL*al-SviSrvNT#qU;Hguw`3jo$?pk5 zhoqUab(UX`1W^8ekEyG<>wlNGzSblEnM~<*PU|{YP2BUJ&ozHv3;HgXd=PG(E51+f zUP;8FTm1L@vf5|y#0R|aTz#*p67Y$Kyzef064_VcaM0&nRpb@?$5***U-8X`s@i?R z-Yixs1q`nb01?Ndiux^dw#lR#-kn4VY7gbuy<@}m;i=V7dLW_19pAszQrXf?l@4xc z?uW)#X@!8)+duDV0CjCoJEEv}MN;VIt*>Fs|DrHvHR)lkUJC*a=XvO*9wZ#Bc&D># zH1g(AA#D$C1&FW8w~xVn-gsk&@H#fIujkwTiovp+t$hua{TeL$zbIJtOGm-h$=|Bl z*C^OO7X@40fv&ELZSKe=|8#XD=c7lD@GfRxEsL#oVYP@BV1Lgh{1Q{~=ySZ>a{o0M zC(wCafkHDuS5R(x=U|)Vg}BUmjyrJ@O+Vrdr`nCyti=ac@ZgUM*amCOkcsrz8|5{p zR$*GV1d`?&s{k zT2r)QMpW`UHsJ=!$TCQj#c<|gOSMCvv&C!`wGvwrUo$VPlq5fkdD zO4i@xD6?5c;=VpiGf-3piHZPG_YoF7LR3bFZheKt(0C{)R>Bz#y}=?Vs~5suLptx# z1WwznHH}+2f1v(N?dNE0s2D1OAtrPcwzf~)S}P|^HaKV}%rrDMTvrub8GOB_X8a++ za%FPgdqCDUAha-o20+>+xUgXWcLp6l=F|fY4OrZnZc|GnR_fODGGCYTW47NVE3I~! z`QO)tamh09SkObJ;=*aV=o}dnRYO-EYdvGo1oUx=uSKYf&wsRNAb*w-`$2H#7^gFj z7T4VRT;SEwt%AQUaP-5I4*Qr-2tLyOXNOO${p&=;{&7s``bW=3;6Hi_h0TWoaXk@~ zHSHowM-W?a3wRSsTOp9gcJdS3JM`0Q1kd=!S~A zpvnl-Xg92^Iv#4ZqEJwGtfICll&yRM8O9EyB)fNaB)xIp2-W*GGcEUi@MvCGkrpxw zY;2^lbYwW~?WD6+w>*?de%5PET02-(@+p7)$9%)7C#dF8g3&v8xjLv|vzxe^>p}{y zY#D+SE%0uxEsJjxybQg?uVJ5J;Ac#?u}2t^H{+s6zD_^lLmMIT*LcG5+db)GLMqmj zhFm47?IsBW#4n6as&l8>y@w9P#xnqJC^@8(U%-!y+JIW=sS&H~#AQQ%Tf66$8x`w^ zgkRu3JPFOXsr>xc;qPxRc1_eyG{*1GpmKO6t_eMqO>A6dEdb}|_@wQ31| zpFuUjuc3M(dLB|ys)3q=xVMr*C?~^cU5;L(tjJ=I9Rct=klNzHvy~Qa*Z+WuA36k!RVh4eNvWSeodI1SmEe^!Do{9#}9DOohr8 zJ~@hicj+HpnbPG+bsSf$KPNcw|A2ilEA4}sevBa(y-9YfrZE+M>;#?*Lb-Y@riQo_8oezHt#pnShu1~jfefrN$A(mF_-QY3gl3LUIzh8s zsmY-ybRThvPc4VuB6kIDLZ`tlO(ea=+8d#S4E(ZX3rdmVcU$gO{ZyBrw7-N$Say&5 zxK%YDYJi^a9Bs1^iwWmW)w}9-uE3Uq7VZyohYok^Qzm3zs!N>pEw6Ml z`?Tzz8Fxry`^KxW-Mgiklok1cc1iG+G74WwN(((N?QtCe$jWn6W9ksDquGphk$;;> z*gMRiYJ+ia(be5|l7E&|pw!0JIl0TvX{U1GH_l%-ECY!%D z%yzyMJYDDNRa~iM@pBVWBdN+Rx37}i_jHO;#{Zm|QDo$akTT7Ss#jqI;Qg!J(F%+; zXOoG)TIB<;YTC<8b?ZzuSU`kc;W$03D|F|_=fwyZZ!vZ4-~#2{#N( zqZK`u3{;eh%iMl05R@VUP=c$cL(s9T95#PGd?u0^MYB)bV>+YJx=v{jaPQBkx2 zP@Uq*YT=Tc0L~l^A@jZw)UlT2b5v}JIyEHUDu~u)L&m6{7QVBAnTK}1$G3kfG9ltU ziCQGYA|4|@oU6Md;0J%UTt!3gmMW?ekHz_5=vZ{i3a+qT8w7zD@Q{C=Q9Z`wT9eB_;l7CTk}xSs zwf4~R4FI1W^lS8Nqt*+ZPSp&_q-F+;U6uH*qs{ji_@kiddFmwQ ziZ5)2)dd7jtJ{}n2 zNEOqWXB*&88*;tj!+0Q$brk2}97^L$mgd)h1?^xxisjTT;~iMd9a&76kmF$gpqJAsJW#smQPl)$+L zP@B#xBI21Ooxtp1Ocl|=Su&{@d4nu6rlA^TE0RFIvK7WO3YQX|!u$@%(KWEHVh)5# z`Krj*R9Jv~l#epFR%T|9U!)3i`OV0AqvgD*a%3P(lkgZ>G;AOB5@io5tKnB)jv`Kc zb0)tyosIZa4*vXr{HZ7ev6H%;hS9M{NM8-aIYa?xl_yya!~6$&M#~g8GE&!7RL%qb z@-!RKVx*tEvAgmE$>}2^`6?4n82O7)^lVnpsbicY!-WSu-el{=3=sHJ%+hFyo>01j z3}34$DXf#@19?z=zF>EbaYo4dr!`xzCXuU803c7G=>(KHTb}dAd)7@9YHOM1$uL=9 zhwxu-FqJ^+VPKe*_C{-cVf^&9l=1M*nj)rWT)1mE3qk>#aX4VJL3a%dwSvg4dC-bt zSMW6;eXwY(ux%T`iE-|b&Oj)HKRl=C1U0kcSoBZPDcG~Ez1~p=_UpIwqu=3t%IN8b z{#8vo>?ztw+wHamqK;@hJ;Dk#9pohKmGp}BUV@BtF>JC0t&Z2qN&zg`q4nHFjhO_z ze09bX4>_59r6YBPX{L3Y9(34|jnQ%n>M?n6S4=+eV>05H6y7S)DKHL0wwlx!6*SpN z45y|XM?FLxOI_s>n9t$am1C18ib`rNc-5~GE}X-FmPeVE;Nv%4C1C$Tn3oa4!4exhq&jYfdsy(fG_BOLe8drNvuq#iBS1TAUcW#)ORqvyLlgZKJTX5-tAh9 zr;1`#sbYn{q9VFHYi`2VX4miVr~Cu#*tcgnE3$&ge@6@hQU(H4*JLPoaHjcJD71F;r%a* zY(x7pU#A{^=3dz78kMbCQ)D5UmfhPd?_%v^cD;zn3VM23o%ZY~&l5LErdc;z*>bFU zcu&d;_L)oZ=AKWS@H1&=P_k;ZUR<*9FPM}y^y}%Q)Nx$dZuEZY0x*_*DMPY#*+r8Q zr2G-U7$uS18)*cQt01{l&X&q0aeKjR6(TUHKDo)ajYvG>9eVD-Zglt`xeV7d!em_x zK@@d|t96%DYd!8BM+E@GQEsAuKi_jWr*;Z#<7lHPsP@Vi@2o9*C{LI^hwdh=DO@4G zl%M^GUrE&WM*PR@_38QP<(uKfF|y6MIBvl$fgLu9(vJ*px&rYhT7sN%g9W-TKw`|N za}10#j;&#kZJtwr0-akH?~tZ2z-Wg0dVyk5L6on@M)Q~l<6>r)ynu67Hfdp4dxbLJyjnqGw=4D`0VW!4CU7ujt)7^f(SxFr&$B zoFH6Zz@ruM=o%wxkImC=G_4Q5v;K5Z3Tgl9Qm8&YV$rECiK>pb(cyF>uU|3KH<(>h z&n#!z_Km@bPJIZh#?z;4&}UEBfo^f|Qg2Opeegd}O9KQH000080Jn_$T-^K&ufPxh z0D?pS01*HH0BvD#P+@dvW@&C@X>MmOaCzk%Yj4{|@_T^%hmC_?#H*sS64yz6c7Vij z5Cg6gC`!{n5ClF}7S9x^k}FHRyZ-l^*$271TvB%G-W50kjV12v%+#DEqw#x8 z4vh|4-CsxQNp$;sp#4&U4r>ebCNtsG>^d&rs-s>fRUF zDSKRS^2OP5#mYUo$~3DesNu6QN4tld0|ZU!vXrDM5U}ICum+M29xesqkGs_FgDq7P4-0v)It>$4`46dqWDmY z;zwH)qz9VHa_UvE8gIk>rcjy_YBq<%cJValy;qipGc_Gm7O5a>i4LFJB5k16q&7Tg z6_^!6hO}0)VbR1yE3EyH_g07(QBYjW3&$zOf+*mZ3k5Ll5B5S&;i z_aJdP&nz4TrKv(98Ce&?`$9A{=~z>jG1#z276%pV8mdcgTcVFm_b`Q1#^?jutuY}V70KV zLACE%0YLI!T1VcyM+&wMuVGiEDA@hxw9|#3Ucqut$T(lylUF&t8T&aZz=<5w3y8)< zkMcZ<*Tz>+wqC)CxbgK#$d0tcqhX8&L+|{g&?tuMe1z!|jD(D*NFn-#l+Ab+)zdT) z&n;!d1!rF)hYw~gF=j0iUrPNvC}+y27Cq0JAaP4@JwuBhM)7*F@FtQ?{ z^fxXFE?bgwRx$?zOt|mu^~#-qTfxF>Y!pBSt}L1r43PKs9fISQCWVG^yf!koBz5zY ztire1CXU!bSB!128tj6iqjYIeZJG|!^}d=+4)%XM?W3 z&*3Sisxo$~1V8W-&5hyMx#q<1d*yOwan!>X<}Q#^K=znl@I8s11-}52xDYs0MlKLo z8e(H))$~0At!D#*_o@MPALoQe_BifGmSaO{2xN~8$N1*;Xmoxya`-*S@gPpwZePBw zqZ4XPvkAA(^!1~qA@5C3&-K*s!CP29jtsWu`%=9xEs?=HD55Nt()7kwrIZ)yO}Gu+ z_PF`-30%1fDHonypgksEcsC(TU>%8GzweN2Gcp~xLxdgSoRgjGUM(j%yLZoO!1iCpc=r=8ws!s z$Qkwps&WC|A+q72Ds}*H0W}!$H$aN7hO-4^Tq>wEjfi{Kv^+`tWf1uUjK>XXasAcA#X0tWB4e=p3|idbsNYFO2ZICQ-(TTWij@5kPwt*PGdiG23ztMvcDa^^T_crdEt?N8>6K@`izfi z4CB~y2PFzlu4Uzdf&J~fckdiVr)jBIeFn-yP8#PvRxsRHL^ZzT7L(;mG&x9lHJj6G z1AJf;t`ZpLjRdHDX^;zJJtWT!V=Y9%E9+rN!*&9lmf|#&6?y=Daj#yH9#H-(i9L_Y z^PPMg!eIl>j&yv7$4Mkj1Eh1I= z<3i=KA68m}gl?eKxz0UnZ^%Aat+hBQWiwF3?H<9fwddT{5QV-f&1?kEhn9CM2Br{L z9^ddql3&ESzlv;TLFJZ5b6$(WfICi!$3O#qkm;)iymHRYP*dF2nPF{G@n>Djp1@g=d!g$^fa0te|p6yudet}V+ z^tR;Hi8c;WEcG|eW(jDe zXXk--O9fs%+wYU%!9IT2_Z(}jGIu*8ZBK2v2#?w_%{yhbjg*t){sD-X{9fYs(!(||ZZKY%Y<&2KpI~Nw6#4}dA;n)peJrI<*d`9C zYjdD4#$AiYc(*Ih${f<2XiMrV*aJZ0LcLD_P-MRsa5kbT52yi12 z7;o7U&;+TS=DJd^O0te#NL7M%Q^2NOqwkrhl94u5t$~vq_)*l0JcA5J2J2|b#6l|M0<0(j;Vi2l zTEy&)0<)TB_-q!K7hV0LAIdOSL}{uy9n>prg~DkWf`8wUf5*J46WUdHKV>cGH3FU;!SG7nJ7FAhldw)AJ zNE@-I4l(LPX?&;5=l*gXh?vhXyD{E8w-zONpP-r4r(M>;jmB_>zruGmPxL4e>)V*tDqpK{x!*X@c?7< zdBX`zh6V{cHMXT;VK@+-_l6I<|wSkGCdG3gn*h)&snd8PL#Q7kEs{7GVBS z0oe-`t(mKgH@U@~BlE%>KKGX4YG?OVmOGK`>rPEgx@)G(E~l_|+4hP+{gOOb&=xSsa zJiuLu+Q8}jnsY{c&gd?40zN0W+Z@hY(8ZgScjH(~yg!nEo2OfR?|Qo8lB}QwJLT}W zsRd$?qq&cQyWD{az^ynOL|{e6z~N__Fs;$n2E|iM)l`r^470`HZhSmRKO{Keb zt9sgI<8GBSl*BqVta|=;MQ)z08C6;BHzl(>lT}grI+JI4UGU7fEEs@WmijhZoj7m_ z5)s#(&945=O@^YBVUrVdZZEz4Bd#bMYlmk>D@fc?sE6OX&fK#?E~IHLio&$|$W71b zr4*jyN;a<3tfq)siZ@Nb60^}Q&~do$no3)GO!&p1?3za6D$NxhJg>E%ChTk~G?agv zmck!@k@6mnO+nU@u^Y`;}%PthB@^MO0=KWq2o(4yaZTe z=`Q0?KMSjNyE27lgfxa7j=t^JeQm)~ga7`73>!vd1HB^8pycUEO@jG(WsS}zc-15P=N3IpsmUf%mqO(RZUP&{PUEJQ{Dsk2{zgVBv;C}9BIr71{SQz} z0|XQR000O8Ghq5$BYAyt`UL<0!Vmxe3jhEBb9rrTWpr|RE^v9JS5a@yVmc97K_ZvbfT@sH73FfRoAoBA+*rZib9!G$!z75c)(O)N!fxDmFK`M(+Ivv5yli(nX$1fR6$!gok*<`l?p2f zgQU{Xq?oeout{YR@Nn9O?bgUtChQ!x#D6Jp*SYho;CeUp=i;iA>CwlYeQA#A4my18 z!r8BHpT1rGdiCk^TY9HXQ<*fiO2apDOOD&uN3(X8t~9-&e%jkv*i)ytT9u<6=K!2c>U=uv9Dc2wS+`} z?fG81YIWLqd$EI=1>e%=?pFLC{X;cqQzhrV>k7Mgf3Mwq)NUiwk&;74cB;JSx_R@t ze}K+}NCCcRzZt8UUoP(ASu8Hy`Xf2mOYeZ(-bI)5#eZAP@vGal@8_4_U$3s6ZU6M=);90mFQ06e z&(E{3EqI*n@_S~MRb*V zS@%LPDg5l2NN-2sqW;HGJbUIoHeax+GJkgYojrl!rEKTE-%IS6`&!8ATamZ%mE%2S z3|V%ooqw<6n74I38{>CI%P-risv0*+=w=&!I%Kxz!_ton)%IQarM7tAuE@z}x5V7u zd!{kVF@5*us$FTqYFl?OZC$-?dT?1=eWLK9-~Y}p*~;;7!ZyVhGc3YSe*tz&w(sOH zH_9(7SfUrY>P^v0(V+GGo?oihefU$;{du3QM7N=sd=KYZxxnX3E+*StF}AZFBb7eukrUb`Sakw;%z3ot4fWI&;BmqDtY(Bv8Qdqla&SD z9a-baTE(_!)xy&(h587JKR{`$ znU=T9R;B#=-H76y56tc#t$omS^S}!;{ig*pe9LoY6npJmTc2>uKf@&F_NKkJ@AgkO z@VR>M$nk_98yQ}0Ev^l}`}=r!Q@ZziHr@O;;aQ`(A#s_?%k&^R?XHXLBF0VSZTjBk;u|PJy~Gjpt`8q<++JoqfjnLpjaB za@U>E>oF-aeU$S(o@mQRt;^HZ-4?u~uhd?#Vo{O$Yu(%>XI7N|Z{d1;Z&~lj`5ByF zrRA>53yEBay>9pZchc>7Da#rCoWhU2D6F@c!nv>YYhl=q%5sUjxtrN9&b+VtZmvm* zpS8TT`0o?% z@#qoPo}UhLUZ=6T&7XWba1Q%)jW!NGkyVw}mumi3Z1Fykxnzf^*ng=Xs__TD>)ua1 zQ?+rir7GK%f(I`G-?8)589sD>pts`BdhT1Xb0ZrCJRx7(cvU|n1 z_VDzV6BnO2TrB7sSK%__K|!_G@5{bkhSqccFb8-uGKnzYK1T&uPapxzQ&o`lW83?W z(A)#8dY~r(AZx-l5RcGgq6gB1HY|^<9b3hQ(5`HcQq2W;v$BCCS%B~@kalwg@c?^& Br@jCH literal 0 HcmV?d00001 diff --git a/generalist_v3_results.json b/generalist_v3_results.json new file mode 100644 index 0000000..c2d3b44 --- /dev/null +++ b/generalist_v3_results.json @@ -0,0 +1,48 @@ +{ + "engine": "fcode 2.3.2.dev29", + "base": "bots/generalist-v2 (live submission v89 source)", + "change": "one safe-builder bounded countergun trade under two or more visible core-hitting turrets; the 120-Ti reserve is waived only for this trade", + "mechanism": { + "champion_showdown_16_games": { + "plans": 20, + "builds": 4 + }, + "second_opponent_family_builds": 0 + }, + "gates": { + "generalist-v2": { + "wins": 90, + "games": 168, + "core_kills_for": 79, + "core_kills_against": 69 + }, + "champion": { + "wins": 135, + "games": 168, + "core_kills_for": 120, + "core_kills_against": 21, + "control_wins": 125 + }, + "lastpop2": { + "wins": 153, + "games": 168, + "core_kills_for": 147, + "core_kills_against": 14, + "control_wins": 149 + }, + "OogwayOld": { + "wins": 146, + "games": 168, + "core_kills_for": 143, + "core_kills_against": 15, + "control_wins": 142 + } + }, + "sha256": { + "main.py": "CB6CD12A6FCFFA76A2A200A7860F738B229DF560DC310F432E68F7FA39407193", + "initialSpawning.py": "E6D48213A505729ED98BBBC1B55623484BC83B202B2E78D4FA9E929686D40BB6", + "mapPathfinding.py": "971A102A26E8792E6B0FED6FE8F7710641FD1D03D299322F97423A43342AE4A6", + "symmetry.py": "8C4AB5843AB90F8C0907E261E2049B75588BC88BFB8CEEA2CC236E3CD10CD9A1", + "generalist-v3.zip": "78A450D36F8CC7102822F96C39326A6D656E49C33ECF15A42C38898B10E502DD" + } +} diff --git a/live_baseline.json b/live_baseline.json new file mode 100644 index 0000000..6e3e62d --- /dev/null +++ b/live_baseline.json @@ -0,0 +1,10 @@ +{ + "bot": "oogerebus3", + "source_commit": "24491ff", + "recorded_engine": "2.3.0.dev26", + "files": { + "initialSpawning.py": "E6D48213A505729ED98BBBC1B55623484BC83B202B2E78D4FA9E929686D40BB6", + "main.py": "08056C8E2571B6AA45C927CD1A139859CE049335807F4EFDF011F2ED7D41A422", + "mapPathfinding.py": "971A102A26E8792E6B0FED6FE8F7710641FD1D03D299322F97423A43342AE4A6" + } +} diff --git a/live_replay_audit.py b/live_replay_audit.py new file mode 100644 index 0000000..1fd276b --- /dev/null +++ b/live_replay_audit.py @@ -0,0 +1,383 @@ +"""Batch-audit live Florent replay files for general weakness categories. + +The tool queries match metadata, then decodes local replay files to compare +core-race timing, turret pressure, healing, builds, and attrition across wins +and losses. + +Example: + python live_replay_audit.py REPLAY_DIR \ + --team-id 56ced8d6-c986-4ebd-9b0b-ff53de527a85 \ + --fcode /mnt/c/Users/subodh/Downloads/.venv/bin/fcode + +Run it inside WSL when --fcode is a WSL path. +""" + +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +import json +from pathlib import Path +import re +import subprocess + +from replay_stats import PAYLOAD_TYPE, signed, walk + + +MATCH_ID_RE = re.compile( + r"^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})" + r"_game_(\d+)\.replay26$", + re.IGNORECASE, +) + + +def first_varint(message, field, default=None): + return message.get((field, "v"), [default])[0] + + +def first_message(message, field): + values = message.get((field, "m"), []) + return walk(values[0]) if values else {} + + +def position(message): + return ( + first_varint(message, 1, 0), + first_varint(message, 2, 0), + ) + + +def core_tiles(top_left): + x, y = top_left + return {(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)} + + +def distance_sq_to_tiles(pos, tiles): + return min((pos[0] - x) ** 2 + (pos[1] - y) ** 2 for x, y in tiles) + + +def fetch_match(fcode, match_id): + output = subprocess.check_output( + [fcode, "match", "info", match_id, "--json"], + text=True, + encoding="utf-8", + ) + return json.loads(output) + + +def spawned_entity(create_payload): + outer = walk(create_payload) or {} + entity = ( + walk(outer[(1, "m")][0]) + if (1, "m") in outer + else outer + ) or {} + entity_id = first_varint(entity, 1) + team = first_varint(entity, 2, 0) + pos = position(first_message(entity, 3)) + entity_type = "?" + for field, wire_type in entity: + if wire_type == "m" and field >= 10: + entity_type = PAYLOAD_TYPE.get(field, f"type{field}") + break + return entity_id, team, pos, entity_type + + +def audit_replay(path, metadata, team_id): + match = metadata["match"] + game_number = int(MATCH_ID_RE.match(path.name).group(2)) + game = next(g for g in metadata["games"] if g["gameNumber"] == game_number) + our_team = 0 if match["teamAId"] == team_id else 1 + enemy_team = 1 - our_team + opponent = ( + match["teamBName"] if our_team == 0 else match["teamAName"] + ) + + top = walk(path.read_bytes()) + header = first_message(top, 1) + turns = top.get((3, "m"), []) + + entities = {} + position_occupants = defaultdict(list) + cores = {} + hp = {} + for core_blob in header.get((4, "m"), []): + core = walk(core_blob) or {} + entity_id = first_varint(core, 1) + team = first_varint(core, 2, 0) + pos = position(first_message(core, 3)) + max_hp = first_varint(core, 4, 500) + entities[entity_id] = { + "team": team, + "type": "core", + "pos": pos, + "alive": True, + } + cores[team] = entity_id + hp[entity_id] = max_hp + + our_core_tiles = core_tiles(entities[cores[our_team]]["pos"]) + enemy_core_tiles = core_tiles(entities[cores[enemy_team]]["pos"]) + + builds = defaultdict(Counter) + deaths = defaultdict(Counter) + first_build = defaultdict(dict) + core_damage = defaultdict(int) + core_healing = defaultdict(int) + first_core_damage = {} + first_core_shot = {} + core_shots = defaultdict(int) + core_shots_by_round = defaultdict(Counter) + core_attack_sources = defaultdict(Counter) + near_home_turret_builds = defaultdict(int) + near_enemy_turret_builds = defaultdict(int) + builder_deaths_before_core_damage = defaultdict(int) + + def current_entity_at(pos): + for entity_id in reversed(position_occupants.get(pos, [])): + entity = entities.get(entity_id) + if entity and entity["alive"]: + return entity + return None + + for round_index, turn_blob in enumerate(turns, start=1): + turn = walk(turn_blob) or {} + for event_blob in turn.get((1, "m"), []): + event = walk(event_blob) or {} + if (1, "m") in event: + entity_id, team, pos, entity_type = spawned_entity( + event[(1, "m")][0] + ) + entities[entity_id] = { + "team": team, + "type": entity_type, + "pos": pos, + "alive": True, + } + position_occupants[pos].append(entity_id) + builds[team][entity_type] += 1 + first_build[team].setdefault(entity_type, round_index) + if entity_type in ("gunner", "sentinel"): + if distance_sq_to_tiles(pos, core_tiles( + entities[cores[team]]["pos"])) <= 36: + near_home_turret_builds[team] += 1 + if distance_sq_to_tiles(pos, core_tiles( + entities[cores[1 - team]]["pos"])) <= 36: + near_enemy_turret_builds[team] += 1 + elif (2, "m") in event and (1, "v") in event: + entity_id = first_varint(event, 1) + entity = entities.get(entity_id) + move = walk(event[(2, "m")][0]) or {} + if entity: + entity["pos"] = position(move) + elif (5, "m") in event: + damage = walk(event[(5, "m")][0]) or {} + entity_id = first_varint(damage, 1) + delta = signed(first_varint(damage, 2, 0)) + entity = entities.get(entity_id) + if entity and entity["type"] == "core": + team = entity["team"] + hp[entity_id] = hp.get(entity_id, 500) + delta + if delta < 0: + core_damage[team] += -delta + first_core_damage.setdefault(team, round_index) + elif delta > 0: + core_healing[team] += delta + elif (12, "m") in event: + fire = walk(event[(12, "m")][0]) or {} + source = position(first_message(fire, 1)) + target = position(first_message(fire, 2)) + shooter = current_entity_at(source) + if target in our_core_tiles: + victim_team = our_team + elif target in enemy_core_tiles: + victim_team = enemy_team + else: + victim_team = None + if victim_team is not None: + core_shots[victim_team] += 1 + core_shots_by_round[victim_team][round_index] += 1 + first_core_shot.setdefault(victim_team, round_index) + if shooter: + source_key = ( + shooter["team"], + shooter["type"], + source, + ) + else: + source_key = ("?", "?", source) + core_attack_sources[victim_team][source_key] += 1 + elif (3, "m") in event or (13, "m") in event: + field = 3 if (3, "m") in event else 13 + death = walk(event[(field, "m")][0]) or {} + entity_id = first_varint(death, 1) + entity = entities.get(entity_id) + if entity and entity["alive"]: + entity["alive"] = False + team = entity["team"] + entity_type = entity["type"] + deaths[team][entity_type] += 1 + if ( + entity_type == "builder" + and team not in first_core_damage + ): + builder_deaths_before_core_damage[team] += 1 + + our_result = "W" if game["winnerId"] == team_id else "L" + our_attackers = { + key for key in core_attack_sources[enemy_team] + if key[0] == our_team + } + home_attackers = { + key for key in core_attack_sources[our_team] + if key[0] == enemy_team + } + return { + "match": match["id"][:8], + "opponent": opponent, + "game": game_number, + "map": game["mapName"], + "result": our_result, + "condition": game["winCondition"], + "turns": game["turnsPlayed"], + "side": "A" if our_team == 0 else "B", + "our_builders": builds[our_team]["builder"], + "enemy_builders": builds[enemy_team]["builder"], + "our_harvesters": builds[our_team]["harvester"], + "enemy_harvesters": builds[enemy_team]["harvester"], + "our_gunners": builds[our_team]["gunner"], + "enemy_gunners": builds[enemy_team]["gunner"], + "our_sentinels": builds[our_team]["sentinel"], + "enemy_sentinels": builds[enemy_team]["sentinel"], + "our_builder_deaths": deaths[our_team]["builder"], + "enemy_builder_deaths": deaths[enemy_team]["builder"], + "our_early_builder_deaths": builder_deaths_before_core_damage[our_team], + "enemy_early_builder_deaths": builder_deaths_before_core_damage[enemy_team], + "damage_to_our_core": core_damage[our_team], + "damage_to_enemy_core": core_damage[enemy_team], + "our_core_healing": core_healing[our_team], + "enemy_core_healing": core_healing[enemy_team], + "enemy_heal_fraction": round( + core_healing[enemy_team] / core_damage[enemy_team], 3 + ) if core_damage[enemy_team] else 0.0, + "first_home_shot": first_core_shot.get(our_team), + "first_enemy_core_shot": first_core_shot.get(enemy_team), + "home_attacker_count": len(home_attackers), + "our_core_attacker_count": len(our_attackers), + "max_home_shots_round": max( + core_shots_by_round[our_team].values(), default=0 + ), + "max_enemy_core_shots_round": max( + core_shots_by_round[enemy_team].values(), default=0 + ), + "our_near_home_turrets": near_home_turret_builds[our_team], + "enemy_near_home_turrets": near_home_turret_builds[enemy_team], + "our_forward_turrets": near_enemy_turret_builds[our_team], + "enemy_forward_turrets": near_enemy_turret_builds[enemy_team], + "first_our_gunner": first_build[our_team].get("gunner"), + "first_enemy_gunner": first_build[enemy_team].get("gunner"), + } + + +def print_table(rows): + columns = [ + "opponent", "map", "result", "turns", "side", + "damage_to_our_core", "damage_to_enemy_core", + "first_home_shot", "first_enemy_core_shot", + "home_attacker_count", "our_core_attacker_count", + "max_home_shots_round", "enemy_heal_fraction", + "our_harvesters", "enemy_harvesters", + "our_gunners", "enemy_gunners", + ] + widths = { + column: max( + len(column), + *(len(str(row.get(column, ""))) for row in rows), + ) + for column in columns + } + print(" ".join(column.ljust(widths[column]) for column in columns)) + for row in rows: + print(" ".join( + str(row.get(column, "") if row.get(column) is not None else "-") + .ljust(widths[column]) + for column in columns + )) + + +def aggregate(rows): + print("\nLOSS CATEGORIES") + categories = Counter() + for row in rows: + if row["result"] != "L": + continue + if row["turns"] >= 1000: + categories["round-1000 economy"] += 1 + if row["condition"] == "core_destroyed": + categories["core destroyed"] += 1 + if row["home_attacker_count"] >= 2: + categories["multi-turret home pressure"] += 1 + if ( + row["damage_to_enemy_core"] >= 150 + and row["enemy_heal_fraction"] >= 0.75 + ): + categories["siege damage mostly/fully healed"] += 1 + if row["damage_to_enemy_core"] >= 400: + categories["lost after major enemy-core damage"] += 1 + for category, count in categories.most_common(): + print(f" {category}: {count}") + + print("\nBY OPPONENT") + for opponent in sorted({row["opponent"] for row in rows}): + group = [row for row in rows if row["opponent"] == opponent] + wins = sum(row["result"] == "W" for row in group) + core_losses = sum( + row["result"] == "L" and row["condition"] == "core_destroyed" + for row in group + ) + print( + f" {opponent}: {wins}/{len(group)} wins, " + f"{core_losses} core losses" + ) + + print("\nLOSS MAPS") + loss_maps = Counter( + row["map"] for row in rows if row["result"] == "L" + ) + for map_name, count in loss_maps.most_common(): + print(f" {map_name}: {count}") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("replay_dir", type=Path) + parser.add_argument("--team-id", required=True) + parser.add_argument("--fcode", default="fcode") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + replay_files = sorted(args.replay_dir.glob("*.replay26")) + grouped = defaultdict(list) + for path in replay_files: + match = MATCH_ID_RE.match(path.name) + if match: + grouped[match.group(1)].append(path) + + rows = [] + for match_id, paths in grouped.items(): + metadata = fetch_match(args.fcode, match_id) + for path in paths: + rows.append(audit_replay(path, metadata, args.team_id)) + rows.sort(key=lambda row: ( + row["opponent"], row["match"], row["game"] + )) + + if args.json: + print(json.dumps(rows, indent=2)) + else: + print_table(rows) + aggregate(rows) + + +if __name__ == "__main__": + main() diff --git a/maps/jackpot.map26 b/maps/jackpot.map26 index eb008d0f41d4a29d92535de25ccf72daa2d74548..c964e698a8c6b64925ef87c6afb10e3c9f6ac959 100644 GIT binary patch delta 55 ocmcb|bdPC*xCn!k5SIW00x$xZ6ScpI04Wd?2EeK&n=)zw0L1wP!T Date: Sat, 1 Aug 2026 17:26:46 +0200 Subject: [PATCH 02/30] measurement fixes + the heal-lock experiment gate.py - the per-game " first" label was a wrong WORD, not wrong data: it always named the bot passed as bot_a (verified against fcode run and the replay - bot_a is team 0). Execution interleaves ABABAB, so nobody is "first". Label is now "as A" and the CSV column is side_a, with a legacy alias so the 9926 historical rows migrate instead of blanking. gate_results.csv was never wrong; whole-history A-seat rates are strongly structured (bridge 84%, skerry 22%), which a scrambled column could not produce. - per-map lines now print the seat split and flag SEAT-LOCKED maps, on decided games only so a crashed bot cannot dress 0/16 draws up as a seat lock. The old collapsed number hid the lock: 8/16 is exactly what a locked map yields when the candidate holds each seat half the time. - default opponent is now generalist-v3, not the stale champion (== oogerebus3, which inverted the ranking of ten bots). - prints the 95% CI and warns when it spans 50%. MEASUREMENT-FINDINGS.md - CORRECTED the noise floor. One draw per n cannot estimate a spread: three repeats of the same 168-game identical-code control gave 49%, 56%, 51% - the middle one PROMOTES a bot over itself. The band is +/-8, not +/-3, and a 168-game PROMOTE is a 1-in-10 coin flip. Use seeds=8. - games are not reproducible at all: 46 of 48 bots call random.* unseeded and --seed seeds the ENGINE, not the bot. Two identical SERIAL runs flipped 8/20 winners on sprint (10/20 with --tle 0); seeding the bot RNG gave 0/16. Not the worker pool, not the TLE. - ladder diagnosis, 200 live games: rank #1/86 at 62%, but 29% in the 150-299 turn window against the top four, with the enemy core fully healing our siege in 12 of 23 losses. Does NOT reproduce locally, so a local gate can only prove nothing broke. heal_audit.py - offline core damage vs healing per side, no API needed. bots/exp_heal_lock_retreat (+ trace twin, + 9 deterministic tests) - role-2 sieger leaves a provably non-converting siege when home is threatened. - 336-game gate vs generalist-v3: 52% (CI 47-57), kills 149-142. No regression, no measurable gain - as expected, since local games never enter the failure regime. Activation proven by replay: 1696 retreats over 168 games. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 32 + MEASUREMENT-FINDINGS.md | 178 +++ bots/OogwayNEW/initialSpawning.py | 120 ++ bots/OogwayNEW/main.py | 424 ++++++ bots/OogwayNEW/mapPathfinding.py | 406 ++++++ bots/aegis-v1/initialSpawning.py | 120 ++ bots/aegis-v1/main.py | 536 +++++++ bots/aegis-v1/mapPathfinding.py | 406 ++++++ bots/bastion/main.py | 772 ++++++++++ bots/exp_heal_lock_retreat/README.md | 74 + bots/exp_heal_lock_retreat/initialSpawning.py | 120 ++ bots/exp_heal_lock_retreat/main.py | 1254 ++++++++++++++++ bots/exp_heal_lock_retreat/mapPathfinding.py | 392 +++++ bots/exp_heal_lock_retreat/symmetry.py | 116 ++ bots/exp_heal_lock_retreat_trace/README.md | 25 + .../initialSpawning.py | 120 ++ bots/exp_heal_lock_retreat_trace/main.py | 1277 +++++++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_heal_lock_retreat_trace/symmetry.py | 116 ++ bots/prime-a/initialSpawning.py | 120 ++ bots/prime-a/main.py | 430 ++++++ bots/prime-a/mapPathfinding.py | 406 ++++++ gate.py | 127 +- heal_audit.py | 54 + tests/test_heal_lock_retreat.py | 106 ++ 25 files changed, 8101 insertions(+), 22 deletions(-) create mode 100644 AGENTS.md create mode 100644 MEASUREMENT-FINDINGS.md create mode 100644 bots/OogwayNEW/initialSpawning.py create mode 100644 bots/OogwayNEW/main.py create mode 100644 bots/OogwayNEW/mapPathfinding.py create mode 100644 bots/aegis-v1/initialSpawning.py create mode 100644 bots/aegis-v1/main.py create mode 100644 bots/aegis-v1/mapPathfinding.py create mode 100644 bots/bastion/main.py create mode 100644 bots/exp_heal_lock_retreat/README.md create mode 100644 bots/exp_heal_lock_retreat/initialSpawning.py create mode 100644 bots/exp_heal_lock_retreat/main.py create mode 100644 bots/exp_heal_lock_retreat/mapPathfinding.py create mode 100644 bots/exp_heal_lock_retreat/symmetry.py create mode 100644 bots/exp_heal_lock_retreat_trace/README.md create mode 100644 bots/exp_heal_lock_retreat_trace/initialSpawning.py create mode 100644 bots/exp_heal_lock_retreat_trace/main.py create mode 100644 bots/exp_heal_lock_retreat_trace/mapPathfinding.py create mode 100644 bots/exp_heal_lock_retreat_trace/symmetry.py create mode 100644 bots/prime-a/initialSpawning.py create mode 100644 bots/prime-a/main.py create mode 100644 bots/prime-a/mapPathfinding.py create mode 100644 heal_audit.py create mode 100644 tests/test_heal_lock_retreat.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..15928fd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ + +## Team memory + +This repo has a shared team memory MCP server. Other engineers' hard-won +knowledge lives there. + +- Call `recall` before any non-trivial task and before assuming how something + works. +- Call `remember` whenever I correct you on a convention, a pitfall, or a + non-obvious reason for how something is built. Record the WHY, not just the + rule. +- Do not record one-off task instructions or anything specific to the current + ticket. + +## Bot iteration policy + +For every non-trivial bot iteration or rebuild, start with an explicit +evidence-driven plan. Diagnose losses as possible general weaknesses, never as +instructions to target a map name, map dimensions, opponent identity, or one +matchup. Implement each reactive hypothesis independently from the current +frozen baseline, require deterministic mechanism tests and graduated gates, +and stack only independently winning features. Re-gate the complete stack +after every addition. Preserve the baseline and do not upload, queue, replace +root, or activate without explicit user approval. + +Gate against the CURRENT LIVE BOT, not `bots/champion` (stale = `oogerebus3`; +it inverted the ranking of ten bots). Always say which reference a win rate +came from. Never read per-map gate lines — identical code swings 1/8 to 6/8 +on them. A 12-game screen eliminates, never promotes. Because ~half the map +pool is seat-decided between closely matched bots, the gate compresses small +real gains toward 50%: treat 52-54% as unresolved, not as refuted. Rerun the +identical-code control (WORKFLOW.md) after any engine bump. diff --git a/MEASUREMENT-FINDINGS.md b/MEASUREMENT-FINDINGS.md new file mode 100644 index 0000000..6512bec --- /dev/null +++ b/MEASUREMENT-FINDINGS.md @@ -0,0 +1,178 @@ +# Measurement findings — 2026-08-01, prod engine 2.3.3 + +Everything here is measured, not inferred. Method notes matter more than the +numbers: several confident claims made during this session were wrong, and the +errors all came from trusting a derived log instead of a direct run. + +## Champion + +**`bots/generalist-v3` = the live bot (v90) = champion.** Nothing beats it. +All figures are full gates vs generalist-v3, 168 games (21 maps x 4 seeds x +both sides, `--tle 10`): + +| challenger | result | +|---|---| +| OogwayNEW | 21% | +| OogwayOld | 14% | +| aegis-v1 | 14% | +| generalist-v2 | 49% (tied — same lineage) | +| exp_bounded_home_counter | 48% | +| exp_healer_suppression | 48% | +| exp_local_responder | 53% | +| **byte-identical control** | **50-52%** | + +No swap was made. Nothing was submitted or activated. + +## The gate is calibrated — but the band is +/-8, not +/-3 + +`bots/exp_emergency_countertrade` is byte-identical to `bots/generalist-v3` +(md5 `bf31fa04b650`). Gating it against itself measures pure noise: + +- Single draws: n=168 -> 50%; n=336 -> 52%; n=288 (18 maps) -> 51%. +- **CORRECTED 2026-08-01: one draw per n cannot estimate a spread.** Three + repeats of the same 168-game control gave **49%, 56%, 51%** — the middle one + clears the bar and PROMOTES a bot over itself. +- Binomial SD at n=168 is 3.9 points, so the 95% band is +/-7.6 and identical + code clears 55% about **10% of the time**. At n=336 that drops to 3%. +- **A 168-game PROMOTE is a 1-in-10 coin flip. Use seeds=8 (336) to decide.** + gate.py now prints the 95% CI and warns when it spans 50%. + +Games are not reproducible at all: 46 of the 48 bots/ call `random.*` unseeded, +and `--seed` seeds the ENGINE, not the bot's Python RNG. Two identical SERIAL +runs flipped 8/20 winners on `sprint` (10/20 with `--tle 0`); seeding the bot +RNG gave 0/16. So this is not the worker pool and not the TLE - never diff two +runs game by game, and never treat a seed as a control variable. + +A hypothesis was tested and REJECTED: that seat-locked maps were compressing +real gains toward 50% and burying good bots. Dropping the locked maps moved the +control 52% -> 51% and the best candidate 53% -> 53%. **No meaningful gain — do +not redesign the gate for this.** The three exp_* bots above were correctly +rejected; they are not better. + +Rerun the control after any engine bump. It is the only way to know what a +number means. + +## Per-map gate output — RESOLVED 2026-08-01 + +The old `" first"` label was a wrong WORD, not wrong data. It always named +the bot passed as `bot_a` (verified against `fcode run` and the replay: bot_a is +team 0). Since execution interleaves ABABAB, nobody is "first", so the label now +reads `as A` and the CSV column is `side_a`. + +**`gate_results.csv` was never wrong, and past per-map analyses are not invalid +on these grounds.** Whole-history A-seat win rates are strongly structured +(`bridge` 84%, `jackpot` 67% ... `skerry` 22%, `duel` 29%) - a scrambled column +would sit at 50% everywhere. + +The 8/16-vs-20/20 "contradiction" was never one: 8/16 is exactly what a +seat-locked map produces when the candidate holds each seat half the time. The +old summary collapsed both seats into one number and hid it. gate.py now prints +the per-seat split and flags the lock: +`strait 4/8 as A 0/4 as B 4/4 SEAT-LOCKED (side B won every game)`. +Lock detection off the gate is now trustworthy; per-map WIN RATES still are not +(n=8, SD 17.7 points). + +Per-map lines are noise regardless: identical code produced `sweden` 1/8 and +`longship` 6/8 in one run. Measure seat effects with `scripts/seat_check.sh` +(both orders, explicit), never by parsing gate output. + +## Turn order + +- Execution order is by **global entity id**, interleaved `ABABAB` + (A-core, B-core, A-builder, B-builder...). It is NOT "all of team A, then + all of team B". Measured with an instrumented probe bot, not inferred. +- With identical code the second player wins **59%** pool-wide. +- Verified seat-locked (direct runs, both orders, 20 games, identical code): + `duel`, `showdown`, `skerry`, `strait` — all **20/20** to the second-listed + bot. `sprint` is balanced (9/20). **The other 16 maps were never measured + directly** — any figure for them came from the unreliable gate log. +- Seat bias is bounded by skill: across a large gap (generalist-v3 vs + OogwayOld, 86-14) skill won 29/32 on locked maps. The seat only decides + between closely matched bots — which is the top-of-ladder regime. +- All 21 maps are **exactly symmetric** (terrain, ore, and core placement + under rot180/mirrorX/mirrorY), so spawn quality is ruled out. Turn order is + the only remaining asymmetry. +- Do not call this "RNG" or try to fix it with more seeds: on `duel` the seed + is inert. + +## Reference opponent + +**`bots/champion` is stale — it is `oogerebus3`, two generations behind live.** +Screened against it, ten bots scored 91-100% and then lost to the live bot; +OogwayNEW screened 83% and lost 21-79. It inverts rankings. Gate against the +CURRENT LIVE BOT and always state which reference a number came from. + +A 12-game screen can only ELIMINATE (0-25% = broken). It can never promote — +identical code reads up to 67% at n=12. + +## Repo hygiene + +- `main` is a superset of every remote branch; `origin/betterOogway` is behind + it (deletions only). Nothing left to merge. +- Byte-identical duplicates: `champion`==`oogerebus3`, + `exp_emergency_countertrade`==`generalist-v3`, + `exp_generalist_stack`==`generalist-v2`, + `core-sniper-v1`==`exp_core_sniper`, `frozen-erebus-v1`==`oogerebus`. + `*_trace` bots are instrumented twins, not independent results — counting + them separately overstates evidence. + +## Ladder diagnosis, 200 live games (2026-08-01, bot v1, 11:25-14:08) + +Team Oogway, **rank #1 of 86**, rating 1675, **62% overall**. Rivals: +OopsGotYourElo 54/95, Besvikomat 12/25, Ouroboros 5/10, Pantheon 4/10. +Everyone else 75-100%. + +**Seat order does not matter live: 63% as A, 62% as B.** The seat effect that +dominates local mirror gates is absent when skill differs, so the spawn-timing +lead below is aimed at something the ladder cannot see. Deprioritised. + +**The loss is a game-length window.** Against the top four rivals: + +| turns | win rate | +|---|---| +| <150 | 27/40 (68%) | +| **150-299** | **10/34 (29%)** | +| 300-899 | 12/21 (57%) | +| 900+ cap | 26/45 (58%) | + +Controlled for opponent it holds - OopsGotYourElo alone (n=95): 64% / **40%** / +60% / 64%. The 24 losses in the window span 4 opponents, 10 maps, and seats +12/12, and 23 of 24 are `core_destroyed`. It is a general mechanism, not a map. + +**Mechanism: our siege damage gets healed to nothing.** From 85 downloaded +replays (`fcode-gate-artifacts/v90-midgame-replays`): + +| group | dmg to our core | dmg to enemy core | our gunners | fully healed | +|---|---|---|---|---| +| WIN <150 | 93 | 633 | 13 | 0/17 | +| **LOSS 150-299** | **722** | **268** | **30** | **12/23** | +| LOSS 300+ | 416 | 260 | 46 | 11/18 | +| WIN 150+ | 537 | 1446 | **122** | 8/20 | + +In the failure window we have MORE gunners than the enemy (30 vs 23) and still +deal a third of the damage, because half those games are fully healed. Winning +a long game took 122 gunners - 4x - i.e. the only answer the bot currently has +to healing is overwhelming it. Four separate `quarry` losses ended at exactly +t184 with heal fraction 1.0, so the failure is deterministic and repeatable. + +**This does NOT reproduce locally.** Same buckets from `gate_results.csv` +(1844 games): vs OogwayOld 98/75/52/41, vs lastpop2 90/100/100/86, vs +generalist-v2 flat 48/55/53/55. Local opponents either die before t150 or are +same-lineage mirrors. **A local gate cannot show a gain on this class of change +— it can only prove nothing broke.** The gain is only observable on the ladder. + +The MECHANISM is locally reproducible even though the win rate is not: mirror +games hit the same heal-lock (`sprint` t350, core B took 490 and healed 490 = +100%). Measure it with `heal_audit.py` (new, offline, no API needed). + +## Open lead for generalist-v3 + +Action order follows entity id, and id is assigned at spawn — so **spawn timing +is a controllable lever**. A unit spawned later acts after more enemy units and +sees a more current board. Falsifiable cheaply: one variant that delays its +first spawn a round vs one that does not, gated normally against v3. + +Already banked in v3, do not redo: `_direct_siege_plans` (main.py ~638) only +accepts gunner seats where `can_fire_from(...)` succeeds against an enemy core +tile, so it cannot plant out-of-range gunners. That was the atoll/lastpopperian +failure mode and it is fixed. diff --git a/bots/OogwayNEW/initialSpawning.py b/bots/OogwayNEW/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/OogwayNEW/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/OogwayNEW/main.py b/bots/OogwayNEW/main.py new file mode 100644 index 0000000..abafc46 --- /dev/null +++ b/bots/OogwayNEW/main.py @@ -0,0 +1,424 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + self.runBestState(ct, myLoc) + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 4 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def coveredTiles(self, ct: Controller, myTeam): + covered = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + covered.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + covered.add((t.x, t.y)) + return covered + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120: # dont attack if u r broke + covered = self.coveredTiles(ct, myTeam) + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 4 + else: + buildingScore = 1 + else: + continue + if (bPos.x, bPos.y) in covered: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/48) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + if dist > 0: + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if self.rayBlockedByTeam(ct, gunnerSpot, attackPos, gunnerDir, myTeam): + continue + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if ct.can_heal(healPos): + ct.heal(healPos) + else: + if myLoc.distance_squared(healPos) > 2: + self.mapPf.moveTo(ct, healPos) + elif myLoc.distance_squared(healPos) > 1: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(healPos) == 1 and ct.can_move(d): + ct.move(d) + else: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + if explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) \ No newline at end of file diff --git a/bots/OogwayNEW/mapPathfinding.py b/bots/OogwayNEW/mapPathfinding.py new file mode 100644 index 0000000..99e21f2 --- /dev/null +++ b/bots/OogwayNEW/mapPathfinding.py @@ -0,0 +1,406 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + self.moveTo(ct, teamCore) + elif myLoc.distance_squared(curEnd) == 1: + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/aegis-v1/initialSpawning.py b/bots/aegis-v1/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/aegis-v1/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/aegis-v1/main.py b/bots/aegis-v1/main.py new file mode 100644 index 0000000..17a92d7 --- /dev/null +++ b/bots/aegis-v1/main.py @@ -0,0 +1,536 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +# REACTIVE LANE BARRIER — the Ijti absorb pattern (15-game autopsy: they +# take the FIRST core hit at t35-55 vs lastpopperian_ and win at t218-285 +# anyway; barriers in the firing lanes are the visible mechanism, 2-12 per +# game, and nobody else on the ladder builds barriers at all). +# Math: barrier = 3 Ti / 30 hp, a gunner shot = 10 -> one barrier eats 3 +# shots. Strictly REACTIVE: nothing is built until an enemy turret actually +# aims at our core, so the mirror cost is zero by construction. +AEGIS_SLOT = 9 # free store slot: one claimant at a time (stampede + # gate — every-builder-responds collapsed 26%/18%) +AEGIS_RANGE = 12 # only builders this close to home take the job + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self._aegisStuck = 0 # turns the aegis walk made no progress + self.attackBan = 0 + self.mapW = None + self.mapH = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26+: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit. OogwayNEW shipped without this armor. + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self.aegis(ct, myLoc): + return + self.runBestState(ct, myLoc) + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 4 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def coveredTiles(self, ct: Controller, myTeam): + covered = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + covered.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + covered.add((t.x, t.y)) + return covered + + def _laneTile(self, ct: Controller, tp, foot): + """First free tile on the straight 8-dir ray from enemy turret tp + to a core foot tile — the spot where a barrier eats the shots.""" + for c in foot: + dx, dy = c[0] - tp[0], c[1] - tp[1] + if not (dx == 0 or dy == 0 or abs(dx) == abs(dy)): + continue # not a firing ray + sx = (dx > 0) - (dx < 0) + sy = (dy > 0) - (dy < 0) + x, y = tp[0] + sx, tp[1] + sy + while (x, y) != c: + p = Position(x, y) + try: + if (ct.is_in_vision(p) + and ct.get_tile_building_id(p) is None + and self.mapPf.getTileEnv(p) not in (1, 2, 3)): + return p + except Exception: + pass + x += sx + y += sy + return None + + def aegis(self, ct: Controller, myLoc: Position) -> bool: + """When an enemy turret aims at our core, nearby builders block the + lane with barriers. Returns True if the turn was used. + NO claim system and NO dedicated walker — v1 elected one claimant + and it pathed into our own ring wall and stood two tiles short for + seven turns while the core died (aegis.log, skerry t58-76). Now: + every builder within range responds OPPORTUNISTICALLY; a barrier is + 3 Ti so a double-build costs less than a single turn of dithering, + and once a lane tile is filled _laneTile returns the NEXT free tile + toward the core — layered armor by construction.""" + tc = self.mapPf.teamCore + if tc is None: + return False + if abs(myLoc.x - tc.x) + abs(myLoc.y - tc.y) > AEGIS_RANGE: + return False + if ct.get_global_resources() < 10: + return False + foot = [(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)] + myTeam = ct.get_team() + lane = None + for b in ct.get_nearby_entities(): + try: + if ct.get_team(b) == myTeam: + continue + if ct.get_entity_type(b) not in (EntityType.GUNNER, + EntityType.SENTINEL): + continue + bp = ct.get_position(b) + if min(abs(bp.x - c[0]) + abs(bp.y - c[1]) for c in foot) > 6: + continue + lane = self._laneTile(ct, (bp.x, bp.y), foot) + if lane is not None: + break + except Exception: + continue + if lane is None: + self._aegisStuck = 0 + return False + d = abs(myLoc.x - lane.x) + abs(myLoc.y - lane.y) + if d == 1: + if ct.can_act() and ct.can_build_barrier(lane): + ct.build_barrier(lane) + return True + return True # adjacent but on cooldown: hold + if d == 0: # 2.3: cannot build on own tile + for mv in CARDINALS: + if ct.can_move(mv): + ct.move(mv) + return True + return True + # walk in — with a greedy fallback, because mapPf.moveTo dead-ends + # against our own ring conveyors (the exact v1 failure) + before = (myLoc.x, myLoc.y) + if self._aegisStuck >= 2: + best = None + for mv in CARDINALS: + n = myLoc.add(mv) + nd = abs(n.x - lane.x) + abs(n.y - lane.y) + if ct.can_move(mv) and (best is None or nd < best[0]): + best = (nd, mv) + if best is not None: + ct.move(best[1]) + self._aegisStuck = 0 + return True + self.mapPf.moveTo(ct, lane) + if (myLoc.x, myLoc.y) == before and ct.get_move_cooldown() == 0: + self._aegisStuck += 1 + return True + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120: # dont attack if u r broke + covered = self.coveredTiles(ct, myTeam) + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 4 + else: + buildingScore = 1 + else: + continue + if (bPos.x, bPos.y) in covered: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/48) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + if dist > 0: + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if self.rayBlockedByTeam(ct, gunnerSpot, attackPos, gunnerDir, myTeam): + continue + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if ct.can_heal(healPos): + ct.heal(healPos) + else: + if myLoc.distance_squared(healPos) > 2: + self.mapPf.moveTo(ct, healPos) + elif myLoc.distance_squared(healPos) > 1: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(healPos) == 1 and ct.can_move(d): + ct.move(d) + else: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + if explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) \ No newline at end of file diff --git a/bots/aegis-v1/mapPathfinding.py b/bots/aegis-v1/mapPathfinding.py new file mode 100644 index 0000000..99e21f2 --- /dev/null +++ b/bots/aegis-v1/mapPathfinding.py @@ -0,0 +1,406 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + self.moveTo(ct, teamCore) + elif myLoc.distance_squared(curEnd) == 1: + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/bastion/main.py b/bots/bastion/main.py new file mode 100644 index 0000000..54533bd --- /dev/null +++ b/bots/bastion/main.py @@ -0,0 +1,772 @@ +"""BASTION — a from-scratch concept bot with ONE thesis: NEVER DIE. + +(19 measured graft failures say mechanisms only work inside a design built +for them. This is the Ijti absorb strategy built AS the design, from their +spec: 15 replays, 12 with barrier placements. They beat lastpopperian_ 4-1 +twice by TAKING the first core hit at t35-55 and winning at t218-285.) + +The three commitments (each half-measure was measured useless alone): + GARRISON two builders live within ~4 of the core, forever. Response to + a new enemy turret in 1-2 turns because the bodies are home. + PRE-ARMOR barriers at t15 in the enemy-facing seat zone (manhattan 2-3, + diagonals first — where siege guns must sit). Own barriers + are PASSABLE to own units, so armor never chokes movement. + REBUILD a broken barrier is replaced within a few turns, forever. + Economics: attacker pays 3 shots / 6 ammo per 30hp barrier, + we repay 3 Ti + one action. With income, the wall is infinite. +Plus: garrison HEALS the core (4hp/1Ti each), and seats a counter-gunner +with line of sight when a siege gun parks (gunner kills gunner in 4 turns; +a builder needs 20 — never plink). +Economy: mech-v1's outward network growth (measured 550+ mined). Fat econ +wins the t1000 tiebreak if nobody dies — that is a WIN condition here. +Attack: at t100, one builder converts to a lastpop-style seat siege — +the grind counter-kill, after the absorb has held. + +Roles by CLAIM SLOTS (store 8/9), never by spawn index — the core bumps +slot 0 before a new builder ever runs (off-by-one) and can bump it again +before its first turn (race): both documented silent killers. +""" + +from collections import deque + +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +DELTA = {(0, -1): Direction.NORTH, (0, 1): Direction.SOUTH, + (1, 0): Direction.EAST, (-1, 0): Direction.WEST} + +AMMO_BUFFER = 30 # gunner shot costs 2; keep a line of guns fed +RESERVE = 5 # never spend the last few Ti +GUNS_PER_RUSHER = 3 # (unused by bastion's siege; kept for run_gunner) + +GARRISON_SLOT = 8 # store: how many garrison claims taken +GARRISON_N = 2 # Ijti keeps ~2 bodies home +HOME_R = 4 # garrison leash (manhattan from core footprint) +PREARMOR_ROUND = 15 # their pre-armor timing on dangerous maps +PREARMOR_N = 4 # barriers in the seat zone +SIEGE_SLOT = 9 # store: siege claims taken +SIEGE_N = 1 # one grinder, after the absorb holds +SIEGE_ROUND = 100 +SIEGE_MIN, SIEGE_MAX = 2, 5 +TURRET_NEAR = 6 # enemy turret this close to core = under siege + + +class Player: + def __init__(self): + self.idx = None # spawn order = role + self.role = None + self.core = None # our core (top-left tile of the 2x2) + self.enemy = None # enemy core guess (point mirror) + self.w = self.h = None + self.walls = set() # known impassable tiles + self.spawned = 0 + self.guns = 0 + self.stuck = 0 + self.last = None + self.chain = None # econ: head of the conveyor line being laid + self._zone = None # cached seat-zone tiles + self._armored = 0 # pre-armor barriers I have placed + + # ------------------------------------------------------------------ + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception permanently destroys this unit + try: + if self.w is None: + self.w, self.h = ct.get_map_width(), ct.get_map_height() + t = ct.get_entity_type() + if t == EntityType.CORE: + self.run_core(ct) + elif t == EntityType.BUILDER_BOT: + self.run_builder(ct) + elif t == EntityType.GUNNER: + self.run_gunner(ct) + except Exception: + pass + + # ---------------------------------------------------------- the core + def run_core(self, ct: Controller) -> None: + if self.core is None: + self.core = ct.get_position() + ti = ct.get_global_resources() + # keep the crew coming — builders are how everything happens + if ti > ct.get_builder_bot_cost() + RESERVE: + for p in ct.get_nearby_tiles(): + if ct.can_spawn(p): + ct.spawn_builder(p) + self.spawned += 1 + ct.write_store(0, self.spawned) + break + # feed the guns + ammo = ct.get_global_ammo() + if ammo < AMMO_BUFFER and ti > 90: + amt = min(AMMO_BUFFER - ammo, ti - 80) + if amt > 0 and ct.can_convert_ammo(amt): + ct.convert_ammo(amt) + + # ------------------------------------------------------- the builders + def run_builder(self, ct: Controller) -> None: + pos = ct.get_position() + self.observe(ct) + if self.core is None: + for b in ct.get_nearby_buildings(): + if (ct.get_entity_type(b) == EntityType.CORE + and ct.get_team(b) == ct.get_team()): + self.core = ct.get_position(b) + break + if self.core is None: + return + if self.enemy is None: + self.enemy = Position(self.w - 1 - self.core.x, + self.h - 1 - self.core.y) + if self.role is None: + taken = ct.read_store(GARRISON_SLOT) + if taken < GARRISON_N: + ct.write_store(GARRISON_SLOT, taken + 1) + self.role = "garrison" + else: + self.role = "econ" + self.idx = ct.read_store(0) + # decide AT the trigger round, never before (latch-bug lesson) + if (self.role == "econ" + and ct.get_current_round() >= SIEGE_ROUND): + taken = ct.read_store(SIEGE_SLOT) + if taken < SIEGE_N: + ct.write_store(SIEGE_SLOT, taken + 1) + self.role = "siege" + # unstick: bots that haven't moved in a while take any legal step + if self.last == (pos.x, pos.y): + self.stuck += 1 + else: + self.stuck = 0 + self.last = (pos.x, pos.y) + + if self.role == "garrison": + self.do_garrison(ct, pos) + elif self.role == "siege": + self.do_siege(ct, pos) + else: + self.do_econ(ct, pos) + + # ---- role: garrison — the thesis + def _seatZone(self, ct: Controller): + """Enemy-facing tiles at manhattan 2-3 from the core footprint, + diagonals first — where siege guns sit (lastpopperian_ autopsy: + seats at d2-5, diagonals preferred) and where Ijti pre-armors.""" + if self._zone is not None: + return self._zone + foot = [(self.core.x + a, self.core.y + b) + for a in (0, 1) for b in (0, 1)] + eg = (self.w - 1 - self.core.x, self.h - 1 - self.core.y) + cd = abs(self.core.x - eg[0]) + abs(self.core.y - eg[1]) + out = [] + for dx in range(-3, 5): + for dy in range(-3, 5): + x, y = self.core.x + dx, self.core.y + dy + if not (0 <= x < self.w and 0 <= y < self.h): + continue + if (x, y) in foot: + continue + d = min(abs(x - c[0]) + abs(y - c[1]) for c in foot) + if not (2 <= d <= 3): + continue + if abs(x - eg[0]) + abs(y - eg[1]) > cd: + continue # home side stays open + diag = (x != self.core.x and x != self.core.x + 1 + and y != self.core.y and y != self.core.y + 1) + out.append((0 if diag else 1, d, Position(x, y))) + out.sort(key=lambda t: (t[0], t[1])) + self._zone = [t[2] for t in out] + return self._zone + + def _laneTile(self, ct: Controller, tp, foot): + """First free tile on the straight 8-dir ray from an enemy turret + to a core foot tile — a barrier there eats the shots.""" + for c in foot: + dx, dy = c[0] - tp[0], c[1] - tp[1] + if not (dx == 0 or dy == 0 or abs(dx) == abs(dy)): + continue + sx = (dx > 0) - (dx < 0) + sy = (dy > 0) - (dy < 0) + x, y = tp[0] + sx, tp[1] + sy + while (x, y) != c: + q = Position(x, y) + try: + if (ct.is_in_vision(q) + and ct.get_tile_building_id(q) is None + and ct.get_tile_env(q) == Environment.EMPTY): + return q + except Exception: + pass + x += sx + y += sy + return None + + def do_garrison(self, ct: Controller, pos: Position) -> None: + """Priorities, in Ijti's measured order: + 1. heal a damaged core (4hp per builder-turn, 1 Ti) + 2. block the lane of any enemy turret at the doorstep (this IS the + rebuild loop: when the barrier dies the lane opens and this + fires again — observed on Ijti: replacements every ~4 turns) + 3. counter-SEAT a gunner with line of sight to that turret + 4. pre-armor the seat zone from t15 + 5. hold the leash, mine opportunistically, never leave""" + foot = [(self.core.x + a, self.core.y + b) + for a in (0, 1) for b in (0, 1)] + ti = ct.get_global_resources() + # 1. heal + if ct.can_act(): + for c in foot: + cp = Position(c[0], c[1]) + if manhattan(pos, cp) == 1: + try: + cid = ct.get_tile_building_id(cp) + if (cid is not None + and ct.get_hp(cid) < 500 and ti > 2 + and ct.can_heal(cp)): + ct.heal(cp) + return + except Exception: + pass + # find the siege gun, if any + threat = None + for b in ct.get_nearby_entities(): + try: + if ct.get_team(b) == ct.get_team(): + continue + if ct.get_entity_type(b) not in (EntityType.GUNNER, + EntityType.SENTINEL): + continue + bp = ct.get_position(b) + if min(abs(bp.x - c[0]) + abs(bp.y - c[1]) + for c in foot) <= TURRET_NEAR: + threat = bp + break + except Exception: + continue + # 2. lane barrier / rebuild loop + if threat is not None and ti > 5: + lane = self._laneTile(ct, (threat.x, threat.y), foot) + if lane is not None: + d = manhattan(pos, lane) + if d == 1: + if ct.can_act() and ct.can_build_barrier(lane): + ct.build_barrier(lane) + return + elif d > 1: + self.step_to(ct, lane) + return + # 3. counter-seat: a gunner kills a gunner in 4 turns; a builder + # needs 20 — never plink (2nd-place Turret Takedown) + if (threat is not None and ct.can_act() + and ti > ct.get_gunner_cost() + RESERVE): + for d in CARDINALS: + spot = pos.add(d) + if not (0 <= spot.x < self.w and 0 <= spot.y < self.h): + continue + try: + if not ct.is_in_vision(spot): + continue + if ct.get_tile_building_id(spot) is not None: + continue + for f in DIRECTIONS: + if not ct.can_fire_from(spot, f, EntityType.GUNNER, + threat): + continue + if ct.can_build_gunner(spot, f): + ct.build_gunner(spot, f) + return + break + except Exception: + continue + # 4. pre-armor + if (ct.get_current_round() >= PREARMOR_ROUND and ti > 20 + and self._armored < PREARMOR_N and ct.can_act()): + for z in self._seatZone(ct): + try: + if not ct.is_in_vision(z): + continue + if ct.get_tile_building_id(z) is not None: + continue + if ct.get_tile_env(z) != Environment.EMPTY: + continue + except Exception: + continue + if manhattan(pos, z) == 1: + if ct.can_build_barrier(z): + ct.build_barrier(z) + self._armored += 1 + return + break + self.step_to(ct, z) + return + # 5. hold the leash — mine adjacent ore opportunistically + homeD = min(abs(pos.x - c[0]) + abs(pos.y - c[1]) for c in foot) + if homeD > HOME_R: + self.step_to(ct, self.core) + return + if ct.can_act() and ti > ct.get_harvester_cost() + RESERVE: + for t in self.visible_ore(ct): + if manhattan(pos, t) == 1 and self.touches_net(ct, t): + if ct.can_build_harvester(t): + ct.build_harvester(t) + return + + # ---- role: siege (the grind counter-kill, lastpop seat logic) + def do_siege(self, ct: Controller, pos: Position) -> None: + target = self.enemy_core_tile(ct) + if target is None: + return + if manhattan(pos, target) > SIEGE_MAX + 3: + self.step_to(ct, target) + return + # cap: at most 3 of our guns near their core — v0 planted one every + # turn forever (1000+ creates vs nop) and would bankrupt any econ + mine = 0 + for b in ct.get_nearby_entities(): + try: + if (ct.get_team(b) == ct.get_team() + and ct.get_entity_type(b) == EntityType.GUNNER + and manhattan(ct.get_position(b), target) <= SIEGE_MAX): + mine += 1 + except Exception: + continue + if mine >= 3: + return + seat = None + for t in ct.get_nearby_tiles(): + if not (SIEGE_MIN <= manhattan(t, target) <= SIEGE_MAX): + continue + try: + if not ct.is_in_vision(t): + continue + if ct.get_tile_building_id(t) is not None: + continue + if ct.get_tile_env(t) != Environment.EMPTY: + continue + except Exception: + continue + face = None + for f in DIRECTIONS: + try: + if ct.can_fire_from(t, f, EntityType.GUNNER, target): + face = f + break + except Exception: + continue + if face is None: + continue + diag = (t.x != target.x and t.y != target.y) + key = (0 if diag else 1, manhattan(pos, t)) + if seat is None or key < seat[0]: + seat = (key, t, face) + if seat is not None: + _, spot, face = seat + if manhattan(pos, spot) == 1: + if (ct.can_act() + and ct.get_global_resources() + > ct.get_gunner_cost() + RESERVE + and ct.can_build_gunner(spot, face)): + ct.build_gunner(spot, face) + return + self.step_to(ct, spot) + return + self.step_to(ct, target) + + # ---- role: waller (unused) + def do_wall(self, ct: Controller, pos: Position) -> None: + holes = self.wall_tiles(ct) + if not holes: # ring sealed — go mine + self.role = "econ" + return self.do_econ(ct, pos) + if ct.can_act(): + for h in holes: + if manhattan(pos, h) != 1: + continue + face = self.chain_face(ct, h) + if face is not None and ct.can_build_conveyor(h, face): + ct.build_conveyor(h, face) + return + self.step_to(ct, min(holes, key=lambda q: manhattan(pos, q))) + + def wall_tiles(self, ct: Controller): + """Tiles an enemy gunner would shoot our core from: the ring on the + enemy-facing half, plus the cardinal firing lanes at distance 2-3 + and the 2-step diagonals (facing is 8-way). The HOME half stays + open on purpose — the core spawns builders onto those tiles and + they are its only heal positions.""" + cx, cy = self.core.x, self.core.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self.enemy + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + cand = [] + for x in range(cx - 1, cx + 3): # the ring, enemy half only + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if abs(x - eg.x) + abs(y - eg.y) <= cd: + cand.append((x, y)) + for o in (0, 1): # cardinal lanes + for d in (2, 3): + cand += [(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)] + for a in (0, 1): # 2-step diagonals + for b in (0, 1): + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + cand.append((cx + a + 2 * dx, cy + b + 2 * dy)) + out = [] + for (x, y) in cand: + if not (0 <= x < self.w and 0 <= y < self.h): + continue + p = Position(x, y) + if not ct.is_in_vision(p): + continue + if ct.get_tile_building_id(p) is not None: + continue + if ct.get_tile_env(p) != Environment.EMPTY: + continue + out.append(p) + out.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + return out + + def chain_face(self, ct: Controller, tile: Position): + """Facing for a wall conveyor: it must output into the core or an + existing own conveyor. A dead-end conveyor swallows every harvest + chain that later routes into it (measured: 160 mined vs 1500).""" + foot = {(self.core.x + a, self.core.y + b) + for a in (0, 1) for b in (0, 1)} + best = None + for d in CARDINALS: + n = tile.add(d) + if (n.x, n.y) in foot: + return d + if not (0 <= n.x < self.w and 0 <= n.y < self.h): + continue + if not ct.is_in_vision(n): + continue + bid = ct.get_tile_building_id(n) + if (bid is not None and ct.get_team(bid) == ct.get_team() + and ct.get_entity_type(bid) == EntityType.CONVEYOR): + dd = manhattan(n, self.core) + if best is None or dd < best[0]: + best = (dd, d) + return best[1] if best else None + + # ---- role: economy + # A harvester only pays if its output reaches the core, so this runs as + # two explicit phases: plant one harvester, then walk its conveyor line + # home before planting the next. (v1 planted harvesters and never wired + # them: 0 titanium mined all game.) + def do_econ(self, ct: Controller, pos: Position) -> None: + # RULE: only ever plant a harvester that is ALREADY touching the + # network (core or our conveyors), and only ever lay a conveyor + # that touches it too. Connectivity is then true by construction. + # (v1 chained backward from the harvester and skipped blocked + # tiles, leaving gaps — 1 harvester + 1 disconnected conveyor and + # 0 titanium mined across a whole game.) + free_ore = self.visible_ore(ct) + # A) ore already on the network -> plant it + for t in free_ore: + if not self.touches_net(ct, t): + continue + if manhattan(pos, t) == 1: + if (ct.can_act() + and ct.get_global_resources() + > ct.get_harvester_cost() + RESERVE + and ct.can_build_harvester(t)): + ct.build_harvester(t) + return + self.step_to(ct, t) + return + # B) grow the network one tile toward the nearest free ore + if free_ore: + goal = min(free_ore, key=lambda t: manhattan(pos, t) + + 2 * manhattan(t, self.core)) + spot = self.grow_spot(ct, pos, goal) + if spot is not None: + tile, face = spot + if manhattan(pos, tile) == 1: + if (ct.can_act() + and ct.get_global_resources() + > ct.get_conveyor_cost() + RESERVE + and ct.can_build_conveyor(tile, face)): + ct.build_conveyor(tile, face) + return + self.step_to(ct, tile) + return + self.step_to(ct, goal) + return + ore = None + for t in ct.get_nearby_tiles(): + try: + if ct.get_tile_env(t) != Environment.ORE_TITANIUM: + continue + except Exception: + continue + if ct.get_tile_building_id(t) is not None: + continue + # prefer ore near HOME: the conveyor line back is the expensive + # part (one tile per ~2 turns under act-xor-move), so a close + # patch pays far sooner than a rich far one + score = manhattan(pos, t) + 2 * manhattan(t, self.core) + if ore is None or score < ore[0]: + ore = (score, t) + ore = ore[1] if ore else None + if ore is None: + # No ore in vision. Walking home would park us there forever — + # on a 26x26 map that meant 0 titanium mined all game. Sweep + # outward instead, each econ bot on its own heading. + self.probe = getattr(self, "probe", 0) + 1 + ring = 4 + (self.probe // 12) * 3 + ang = (self.idx * 2 + self.probe // 24) % 4 + dx, dy = ((1, 0), (0, 1), (-1, 0), (0, -1))[ang] + goal = Position( + max(0, min(self.w - 1, self.core.x + dx * ring)), + max(0, min(self.h - 1, self.core.y + dy * ring))) + self.step_to(ct, goal) + return + if manhattan(pos, ore) > 1: + self.step_to(ct, ore) + return + if (ct.can_act() + and ct.get_global_resources() > ct.get_harvester_cost() + RESERVE + and ct.can_build_harvester(ore)): + ct.build_harvester(ore) + self.chain = ore # start wiring from here + return + self.step_to(ct, self.core) + + def visible_ore(self, ct: Controller): + out = [] + for t in ct.get_nearby_tiles(): + try: + if ct.get_tile_env(t) != Environment.ORE_TITANIUM: + continue + if ct.get_tile_building_id(t) is not None: + continue + except Exception: + continue + out.append(t) + return out + + def net_face(self, ct: Controller, tile: Position): + """Direction from `tile` into our network (core foot or one of our + conveyors), or None if it doesn't touch the network.""" + foot = {(self.core.x + a, self.core.y + b) + for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + n = tile.add(d) + if (n.x, n.y) in foot: + return d + if not (0 <= n.x < self.w and 0 <= n.y < self.h): + continue + try: + if not ct.is_in_vision(n): + continue + bid = ct.get_tile_building_id(n) + except Exception: + continue + if (bid is not None and ct.get_team(bid) == ct.get_team() + and ct.get_entity_type(bid) == EntityType.CONVEYOR): + return d + return None + + def touches_net(self, ct: Controller, tile: Position) -> bool: + return self.net_face(ct, tile) is not None + + def grow_spot(self, ct: Controller, pos: Position, goal: Position): + """Best empty tile that touches the network and moves us toward + `goal`, plus the facing that points back into the network.""" + best = None + for t in ct.get_nearby_tiles(): + try: + if ct.get_tile_building_id(t) is not None: + continue + if ct.get_tile_env(t) != Environment.EMPTY: + continue + except Exception: + continue + face = self.net_face(ct, t) + if face is None: + continue + score = manhattan(t, goal) * 2 + manhattan(pos, t) + if best is None or score < best[0]: + best = (score, t, face) + return (best[1], best[2]) if best else None + + def chain_next(self, ct: Controller): + """Next (tile, facing) to lay, walking from self.chain toward the + core. Returns None once the line touches the core or an existing + conveyor — a chain that dead-ends swallows everything routed in.""" + foot = {(self.core.x + a, self.core.y + b) + for a in (0, 1) for b in (0, 1)} + head = self.chain + best = None + for d in CARDINALS: + n = head.add(d) + if not (0 <= n.x < self.w and 0 <= n.y < self.h): + continue + if (n.x, n.y) in foot: + return None # home already + if (n.x, n.y) in self.walls: + continue + if ct.is_in_vision(n): + bid = ct.get_tile_building_id(n) + if bid is not None: + if (ct.get_team(bid) == ct.get_team() + and ct.get_entity_type(bid) == EntityType.CONVEYOR): + return None # joined the existing network + continue + dd = manhattan(n, self.core) + if best is None or dd < best[0]: + best = (dd, n) + if best is None: + return None + tile = best[1] + # face the step after this one (or the core if we arrive) + face, fbest = None, None + for d in CARDINALS: + n = tile.add(d) + if not (0 <= n.x < self.w and 0 <= n.y < self.h): + continue + dd = manhattan(n, self.core) + if fbest is None or dd < fbest: + fbest, face = dd, d + return (tile, face) if face else None + + # ---- role: rush + def do_rush(self, ct: Controller, pos: Position) -> None: + target = self.enemy_core_tile(ct) + # plant a gunner that can actually hit the core from where it stands + if (self.guns < GUNS_PER_RUSHER and ct.can_act() + and ct.get_global_resources() > ct.get_gunner_cost() + RESERVE): + for d in CARDINALS: + spot = pos.add(d) + if not (0 <= spot.x < self.w and 0 <= spot.y < self.h): + continue + if not ct.is_in_vision(spot): + continue + if ct.get_tile_building_id(spot) is not None: + continue + for f in DIRECTIONS: + try: + if not ct.can_fire_from(spot, f, EntityType.GUNNER, target): + continue + except Exception: + continue + if ct.can_build_gunner(spot, f): + ct.build_gunner(spot, f) + self.guns += 1 + return + # standing on an enemy building next to their core: break it + if ct.can_act(): + for d in CARDINALS: + n = pos.add(d) + if not ct.is_in_vision(n): + continue + bid = ct.get_tile_building_id(n) + if bid is not None and ct.get_team(bid) != ct.get_team(): + if manhattan(n, target) <= 4 and ct.can_fire(n): + ct.fire(n) + return + self.step_to(ct, target) + + def enemy_core_tile(self, ct: Controller) -> Position: + for b in ct.get_nearby_buildings(): + try: + if (ct.get_entity_type(b) == EntityType.CORE + and ct.get_team(b) != ct.get_team()): + self.enemy = ct.get_position(b) + return self.enemy + except Exception: + pass + return self.enemy + + # --------------------------------------------------------- the guns + def run_gunner(self, ct: Controller) -> None: + tgt = ct.get_gunner_target() + if tgt is not None: + bid = ct.get_tile_building_id(tgt) + bot = ct.get_tile_builder_bot_id(tgt) + if bot is not None and ct.get_team(bot) == ct.get_team(): + return # never shoot our own + if bid is not None and ct.get_team(bid) != ct.get_team(): + if ct.can_fire(tgt): + ct.fire(tgt) + return + # nothing in the lane: turn toward whatever we can hit + if ct.get_global_resources() < 60: + return + me = ct.get_position() + for f in DIRECTIONS: + try: + tiles = ct.get_attackable_tiles_from(me, f, EntityType.GUNNER) + except Exception: + continue + for t in tiles: + bid = ct.get_tile_building_id(t) + if bid is not None and ct.get_team(bid) != ct.get_team(): + if f != ct.get_direction() and ct.can_rotate(f): + ct.rotate(f) + return + break + + # ------------------------------------------------------------ moving + def observe(self, ct: Controller) -> None: + for t in ct.get_nearby_tiles(): + try: + if ct.get_tile_env(t) == Environment.WALL: + self.walls.add((t.x, t.y)) + except Exception: + pass + + def step_to(self, ct: Controller, goal: Position) -> None: + """BFS over known terrain (unknown assumed passable), one step.""" + if goal is None or ct.get_move_cooldown() != 0: + return + pos = ct.get_position() + if self.stuck > 3: # jiggle out of a jam + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + start = (pos.x, pos.y) + end = (goal.x, goal.y) + if start == end: + return + prev = {start: None} + q = deque([start]) + hit = None + while q: + cur = q.popleft() + if cur == end: + hit = cur + break + x, y = cur + for dx, dy in ((0, -1), (0, 1), (1, 0), (-1, 0)): + n = (x + dx, y + dy) + if not (0 <= n[0] < self.w and 0 <= n[1] < self.h): + continue + if n in prev or n in self.walls: + continue + prev[n] = cur + q.append(n) + if hit is None: # unreachable: drift toward it + for dx, dy in ((0, -1), (0, 1), (1, 0), (-1, 0)): + n = Position(pos.x + dx, pos.y + dy) + if (0 <= n.x < self.w and 0 <= n.y < self.h + and manhattan(n, goal) < manhattan(pos, goal) + and ct.can_move(DELTA[(dx, dy)])): + ct.move(DELTA[(dx, dy)]) + return + return + node = hit + while prev[node] is not None and prev[node] != start: + node = prev[node] + d = DELTA.get((node[0] - start[0], node[1] - start[1])) + if d is not None and ct.can_move(d): + ct.move(d) + + +def manhattan(a: Position, b: Position) -> int: + return abs(a.x - b.x) + abs(a.y - b.y) diff --git a/bots/exp_heal_lock_retreat/README.md b/bots/exp_heal_lock_retreat/README.md new file mode 100644 index 0000000..a329031 --- /dev/null +++ b/bots/exp_heal_lock_retreat/README.md @@ -0,0 +1,74 @@ +# exp_heal_lock_retreat + +Parent: `bots/generalist-v3` (the live bot). One hypothesis, one mechanism. + +## Evidence that motivated it + +200 live ladder games, 2026-08-01, bot v1 (team Oogway, rank #1/86, 62% +overall). Win rate against the top four teams by game length: + +| turns | win rate | +|---|---| +| <150 | 27/40 (68%) | +| **150-299** | **10/34 (29%)** | +| 300-899 | 12/21 (57%) | +| 900+ cap | 26/45 (58%) | + +Controlled for opponent it survives — OopsGotYourElo alone (n=95): 64% / **40%** +/ 60% / 64%. The 24 losses in that window span 4 opponents, 10 maps, and seats +12/12; 23 of 24 are `core_destroyed`. From 85 replays of those matches: + +| group | dmg to our core | dmg to enemy core | our gunners | fully healed | +|---|---|---|---|---| +| WIN <150 | 93 | 633 | 13 | 0/17 | +| **LOSS 150-299** | **722** | **268** | **30** | **12/23** | +| WIN 150+ | 537 | 1446 | 122 | 8/20 | + +In the failure window we field MORE gunners than the enemy and convert a third +of the damage, because half those games are fully healed — while our own core +takes 722. The only long games we win take 122 gunners: the sole answer the +parent has to a healed core is to overwhelm it. + +## The mechanism + +`_observe_enemy_core_heal` watches the visible enemy core's HP and publishes a +net-recovery count to store slot 12 (previously unused). A core that keeps +regaining HP counts up; a core that is losing HP counts back down, so a siege +that starts converting again releases the unit. + +When the count reaches `HEAL_LOCK_TRIGGER` (2) **and** home is genuinely +threatened, the role-2 sieger — the one that already gives up its seat to +counter a defender — runs `_run_home_defense` instead of the siege. Role 1 +never stops sieging, so pressure on the enemy core is never fully abandoned, +and an unthreatened home cannot pull anyone off the core. + +No map name, dimension, side, opponent identity, or turn number is read. Both +conditions are observable state and both release the unit when they clear. + +## Verification + +- `tests/test_heal_lock_retreat.py` — 9 deterministic tests: store round-trip, + first observation cannot trigger, one heal tick cannot trigger, sustained + recovery does, a converting siege releases, evidence bounded both ways, slot + 12 collision-free, and a diff assertion that no parent line was deleted. +- Activation proven by replay, not assumed: the instrumented twin + `bots/exp_heal_lock_retreat_trace` logged **1,696 real retreats over 168 + games** (median round 466, 404 of them inside the 150-300 target window), and + 3,682 lock events of which the home-threat gate filtered 54%. +- The FIRST trace run was discarded: the instrumentation had been inserted into + the parent's pre-existing home-defender block, so its 23,203 "retreats" were + ordinary parent behaviour. Check where a trace landed before believing it. + +## Known weakness + +Evidence pins at `HEAL_LOCK_MAX` (15) in ~1,900 observations, and the release +path needs the enemy core in vision — which the retreating unit no longer has. +Release therefore depends on role 1 still observing. A time-bounded retreat +would be the next iteration if the mechanism is worth keeping. + +## Result + +See the gate line recorded in `MEASUREMENT-FINDINGS.md`. Local gates cannot +show a gain for this class of change — no local opponent reproduces the +150-300 collapse — so the gate is a regression guard and the ladder is the +only place the hypothesis can actually be tested. diff --git a/bots/exp_heal_lock_retreat/initialSpawning.py b/bots/exp_heal_lock_retreat/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_heal_lock_retreat/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_heal_lock_retreat/main.py b/bots/exp_heal_lock_retreat/main.py new file mode 100644 index 0000000..205d2ef --- /dev/null +++ b/bots/exp_heal_lock_retreat/main.py @@ -0,0 +1,1254 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_HEAL_LOCK = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +# Ladder diagnosis 2026-08-01 (200 live games): between t150 and t300 we win +# 29% against the top four teams, our core taking 722 damage while dealing 268, +# with the enemy core FULLY healing our siege in 12 of 23 losses. We field MORE +# gunners than they do (30 v 23) and still convert a third of the damage; the +# only long games we win take 122 gunners, i.e. the sole answer the bot +# currently has to a healed core is to overwhelm it. +# Two net recoveries, not one: a single heal tick is ordinary, a core that keeps +# regaining HP is out-repairing the siege. +HEAL_LOCK_TRIGGER = 2 +HEAL_LOCK_MAX = 15 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def pack_heal_lock(hp: int, evidence: int) -> int: + return ((max(hp, 0) & 0xFFFF) << 4) | (max(min(evidence, HEAL_LOCK_MAX), 0)) + + +def unpack_heal_lock(value: int): + """Return (last seen enemy core hp, net recovery count). (None, 0) if unset.""" + if value == 0: + return None, 0 + return (value >> 4) & 0xFFFF, value & 0xF + + +def next_heal_evidence(previous_hp, current_hp: int, evidence: int) -> int: + """A recovering enemy core counts up, a falling one counts back down. + + Symmetric so that a siege which starts converting again releases the unit + back to the siege instead of latching home for the rest of the game. + """ + if previous_hp is None: + return evidence + if current_hp > previous_hp: + return min(evidence + 1, HEAL_LOCK_MAX) + if current_hp < previous_hp: + return max(evidence - 1, 0) + return evidence + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _observe_enemy_core_heal(self, ct: Controller, target: Position) -> None: + """Publish whether the enemy core is out-healing the siege. + + Pure observation of a visible entity's HP. No map, dimension, side, + opponent, or turn-number input — a bot that repairs faster than we + shoot looks the same here whoever it belongs to. + """ + if target is None: + return + my_team = ct.get_team() + core_id = None + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) == EntityType.CORE): + core_id = building_id + break + if core_id is None: + return + current_hp = ct.get_hp(core_id) + previous_hp, evidence = unpack_heal_lock(ct.read_store(SLOT_HEAL_LOCK)) + ct.write_store(SLOT_HEAL_LOCK, pack_heal_lock( + current_hp, next_heal_evidence(previous_hp, current_hp, evidence))) + + def _heal_locked(self, ct: Controller) -> bool: + _, evidence = unpack_heal_lock(ct.read_store(SLOT_HEAL_LOCK)) + return evidence >= HEAL_LOCK_TRIGGER + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + self._observe_enemy_core_heal(ct, target) + # HEAL-LOCK RETREAT. Only role 2 — the sieger that already gives up + # its seat to counter a defender — and only while home is genuinely + # threatened, so siege pressure is never fully abandoned and an + # unthreatened home cannot pull anyone off the core. Both conditions + # are read from state, and both release the unit when they clear. + if self._sieger_role(ct) == 2 and self._heal_locked(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_heal_lock_retreat/mapPathfinding.py b/bots/exp_heal_lock_retreat/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_heal_lock_retreat/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_heal_lock_retreat/symmetry.py b/bots/exp_heal_lock_retreat/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_heal_lock_retreat/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_heal_lock_retreat_trace/README.md b/bots/exp_heal_lock_retreat_trace/README.md new file mode 100644 index 0000000..a8cd379 --- /dev/null +++ b/bots/exp_heal_lock_retreat_trace/README.md @@ -0,0 +1,25 @@ +# generalist-v3 + +Live-v89 source plus one bounded emergency countertrade: + +- activates only with at least two visible turrets whose real attack patterns + hit the core; +- keeps the responder's builder stand outside every visible attack pattern; +- permits a countergun seat covered by at most one turret; +- waives the 120-Ti reserve only for that one trade. + +No opening, economy, spawning, siege, role, store, map, or opponent-specific +logic changed. + +Dev29 gates (168 games each): + +| opponent | wins | core kills | +|---|---:|---:| +| generalist-v2 | 90 (54%) | 79-69 | +| champion | 135 (80%) | 120-21 | +| lastpop2 | 153 (91%) | 147-14 | +| OogwayOld | 146 (87%) | 143-15 | + +Mechanism trace: 4 completed countergun builds from 20 plans against champion. +Second-family builds were not reproduced locally, so inactive live validation +is still required. See `generalist_v3_results.json` and `HANDOFF.md`. diff --git a/bots/exp_heal_lock_retreat_trace/initialSpawning.py b/bots/exp_heal_lock_retreat_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_heal_lock_retreat_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_heal_lock_retreat_trace/main.py b/bots/exp_heal_lock_retreat_trace/main.py new file mode 100644 index 0000000..e5aacdb --- /dev/null +++ b/bots/exp_heal_lock_retreat_trace/main.py @@ -0,0 +1,1277 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_HEAL_LOCK = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +# Ladder diagnosis 2026-08-01 (200 live games): between t150 and t300 we win +# 29% against the top four teams, our core taking 722 damage while dealing 268, +# with the enemy core FULLY healing our siege in 12 of 23 losses. We field MORE +# gunners than they do (30 v 23) and still convert a third of the damage; the +# only long games we win take 122 gunners, i.e. the sole answer the bot +# currently has to a healed core is to overwhelm it. +# Two net recoveries, not one: a single heal tick is ordinary, a core that keeps +# regaining HP is out-repairing the siege. +HEAL_LOCK_TRIGGER = 2 +HEAL_LOCK_MAX = 15 +HEAL_LOCK_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/heal-lock-traces/heal_lock_trace.log" +) + + +def heal_lock_trace(*parts): + try: + with open(HEAL_LOCK_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + chr(10)) + except Exception: + pass + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def pack_heal_lock(hp: int, evidence: int) -> int: + return ((max(hp, 0) & 0xFFFF) << 4) | (max(min(evidence, HEAL_LOCK_MAX), 0)) + + +def unpack_heal_lock(value: int): + """Return (last seen enemy core hp, net recovery count). (None, 0) if unset.""" + if value == 0: + return None, 0 + return (value >> 4) & 0xFFFF, value & 0xF + + +def next_heal_evidence(previous_hp, current_hp: int, evidence: int) -> int: + """A recovering enemy core counts up, a falling one counts back down. + + Symmetric so that a siege which starts converting again releases the unit + back to the siege instead of latching home for the rest of the game. + """ + if previous_hp is None: + return evidence + if current_hp > previous_hp: + return min(evidence + 1, HEAL_LOCK_MAX) + if current_hp < previous_hp: + return max(evidence - 1, 0) + return evidence + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _observe_enemy_core_heal(self, ct: Controller, target: Position) -> None: + """Publish whether the enemy core is out-healing the siege. + + Pure observation of a visible entity's HP. No map, dimension, side, + opponent, or turn-number input — a bot that repairs faster than we + shoot looks the same here whoever it belongs to. + """ + if target is None: + return + my_team = ct.get_team() + core_id = None + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) == EntityType.CORE): + core_id = building_id + break + if core_id is None: + return + current_hp = ct.get_hp(core_id) + previous_hp, evidence = unpack_heal_lock(ct.read_store(SLOT_HEAL_LOCK)) + heal_lock_trace( + "OBSERVE", ct.get_current_round(), ct.get_id(), + "prev", previous_hp, "cur", current_hp, + "evidence", next_heal_evidence(previous_hp, current_hp, evidence)) + ct.write_store(SLOT_HEAL_LOCK, pack_heal_lock( + current_hp, next_heal_evidence(previous_hp, current_hp, evidence))) + + def _heal_locked(self, ct: Controller) -> bool: + _, evidence = unpack_heal_lock(ct.read_store(SLOT_HEAL_LOCK)) + return evidence >= HEAL_LOCK_TRIGGER + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + self._observe_enemy_core_heal(ct, target) + # HEAL-LOCK RETREAT. Only role 2 — the sieger that already gives up + # its seat to counter a defender — and only while home is genuinely + # threatened, so siege pressure is never fully abandoned and an + # unthreatened home cannot pull anyone off the core. Both conditions + # are read from state, and both release the unit when they clear. + if self._sieger_role(ct) == 2 and self._heal_locked(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + heal_lock_trace( + "HL_LOCKED", ct.get_current_round(), ct.get_id(), + "threat", threat.x if threat is not None else None, + threat.y if threat is not None else None) + if threat is not None: + heal_lock_trace( + "HL_RETREAT", ct.get_current_round(), ct.get_id(), + myLoc.x, myLoc.y, "->", threat.x, threat.y) + self._run_home_defense(ct, myLoc, threat) + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_heal_lock_retreat_trace/mapPathfinding.py b/bots/exp_heal_lock_retreat_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_heal_lock_retreat_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_heal_lock_retreat_trace/symmetry.py b/bots/exp_heal_lock_retreat_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_heal_lock_retreat_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/prime-a/initialSpawning.py b/bots/prime-a/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/prime-a/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/prime-a/main.py b/bots/prime-a/main.py new file mode 100644 index 0000000..e56a395 --- /dev/null +++ b/bots/prime-a/main.py @@ -0,0 +1,430 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26+: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit. OogwayNEW shipped without this armor. + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + self.runBestState(ct, myLoc) + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 4 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def coveredTiles(self, ct: Controller, myTeam): + covered = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + covered.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + covered.add((t.x, t.y)) + return covered + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120: # dont attack if u r broke + covered = self.coveredTiles(ct, myTeam) + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 4 + else: + buildingScore = 1 + else: + continue + if (bPos.x, bPos.y) in covered: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/48) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + if dist > 0: + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if self.rayBlockedByTeam(ct, gunnerSpot, attackPos, gunnerDir, myTeam): + continue + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if ct.can_heal(healPos): + ct.heal(healPos) + else: + if myLoc.distance_squared(healPos) > 2: + self.mapPf.moveTo(ct, healPos) + elif myLoc.distance_squared(healPos) > 1: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(healPos) == 1 and ct.can_move(d): + ct.move(d) + else: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + if explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) \ No newline at end of file diff --git a/bots/prime-a/mapPathfinding.py b/bots/prime-a/mapPathfinding.py new file mode 100644 index 0000000..99e21f2 --- /dev/null +++ b/bots/prime-a/mapPathfinding.py @@ -0,0 +1,406 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + self.moveTo(ct, teamCore) + elif myLoc.distance_squared(curEnd) == 1: + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/gate.py b/gate.py index e00bf8f..d568898 100644 --- a/gate.py +++ b/gate.py @@ -9,16 +9,28 @@ REJECT otherwise Usage: - python gate.py [opponent=champion] [seeds=4] [fast] [quiet] + python gate.py [opponent=generalist-v3] [seeds=4] [fast] [quiet] [maps=name1,name2] python gate.py mybot # full gate: all maps x 4 seeds x both sides - python gate.py mybot champion 1 fast # quick 6-map screen (12 games) + python gate.py mybot generalist-v3 1 fast # quick 6-map screen (12 games) python gate.py mybot oldbot 4 # any bot vs any bot, 4 seeds python gate.py mybot oldbot 16 maps=string,bridge quiet Bots live in bots// with a main.py entry point. See WORKFLOW.md. Draws and crashed/unparseable games count AGAINST the candidate. + +Reproducibility: there is none at the game level. Almost every bot in bots/ +calls random.* unseeded, and --seed seeds the engine, not the bot, so the same +(map, seed, seats) replays differently every run - about 40-50% of close games +flip winner. Seeds are extra samples, not a control variable. Read the win rate +with its CI; never diff two runs game by game. + +Seats: `fcode run ` seats bot_a as team 0 and bot_b as team 1, +and the seat fixes the spawn corner. Both sides act every round - neither seat +"goes first" - so results are reported per seat (`as A` / `as B`), never as a +turn order. Some maps are decided by the seat alone; those are flagged +SEAT-LOCKED and their collapsed win rate carries no information about the bots. """ import concurrent.futures as cf import csv @@ -29,6 +41,7 @@ import shutil import sys import tempfile +from math import sqrt from pathlib import Path from subprocess import run @@ -47,7 +60,11 @@ if not args: sys.exit(__doc__) CAND = args[0] -BASE = args[1] if len(args) > 1 else "champion" +# Default to the CURRENT LIVE BOT, not bots/champion. champion is stale +# (== oogerebus3, two generations behind): ten bots screened 91-100% against it +# and then lost to the live bot, and OogwayNEW screened 83% before losing 21-79. +# It inverts rankings, so it must never be the opponent you get by not choosing. +BASE = args[1] if len(args) > 1 else "generalist-v3" SEEDS = int(args[2]) if len(args) > 2 else 4 if CAND == BASE: @@ -133,15 +150,21 @@ def file_sha256(path): "opponent", "map", "seed", - "first_player", + "side_a", "winner", "condition", "turns", "engine", ] + METRIC_COLUMNS +# side_a was called first_player until 2026-08-01. The name was wrong, the data +# was not: it has always held the bot passed as `fcode run `, +# i.e. the one the engine seats as team 0. There is no turn-order advantage to +# record - both sides act every round; what the seat decides is the spawn +# corner, and on some maps that alone settles the game (see SEAT-LOCKED below). +LEGACY_COLUMN_ALIASES = {"side_a": "first_player"} -def game(first, second, m, seed): +def game(side_a, side_b, m, seed): # Every concurrent game gets a private disposable replay path. The CLI's # default replay.replay26 path is shared and races under the gate's worker # pool, which used to dirty the tracked fixture and corrupt replay output. @@ -151,8 +174,8 @@ def game(first, second, m, seed): [ "fcode", "run", - first, - second, + side_a, + side_b, f"maps/{m}.map26", "--seed", str(seed), @@ -178,13 +201,13 @@ def game(first, second, m, seed): except json.JSONDecodeError: continue if result is None: - return m, first, second, seed, "draw", "unknown", 0, { + return m, side_a, side_b, seed, "draw", "unknown", 0, { column: 0 for column in METRIC_COLUMNS } winner_side = result.get("winner") - winner = first if winner_side == "A" else second if winner_side == "B" else "draw" - candidate_is_a = first == CAND + winner = side_a if winner_side == "A" else side_b if winner_side == "B" else "draw" + candidate_is_a = side_a == CAND candidate_prefix = "a" if candidate_is_a else "b" opponent_prefix = "b" if candidate_is_a else "a" metrics = { @@ -201,8 +224,8 @@ def game(first, second, m, seed): } return ( m, - first, - second, + side_a, + side_b, seed, winner, result.get("win_condition", "unknown"), @@ -221,10 +244,10 @@ def game(first, second, m, seed): # 6, not 12: heavy bots (khaos's map analysis) at 12 parallel engines exhaust # the Windows commit limit -> WinError 1455 + phantom "draw (unknown)" games with cf.ThreadPoolExecutor(max_workers=min(6, os.cpu_count() or 4)) as ex: - for i, (m, first, second, seed, winner, cond, turn, metrics) in enumerate( + for i, (m, side_a, side_b, seed, winner, cond, turn, metrics) in enumerate( ex.map(lambda j: game(*j), jobs), 1): rows.append([dt.datetime.now().isoformat(timespec="seconds"), - CAND, BASE, m, seed, first, winner, cond, turn, ENGINE] + CAND, BASE, m, seed, side_a, winner, cond, turn, ENGINE] + [metrics[column] for column in METRIC_COLUMNS]) if winner == CAND: w += 1 @@ -236,7 +259,7 @@ def game(first, second, m, seed): if winner not in (CAND, BASE): oddballs += 1 if not QUIET: - print(f" [{i:>2}/{n}] {m:12s} seed {seed} {first} first -> {winner} ({cond}, t{turn})") + print(f" [{i:>2}/{n}] {m:12s} seed {seed} {side_a} as A -> {winner} ({cond}, t{turn})") results_path = ROOT / "gate_results.csv" new = not results_path.exists() @@ -248,7 +271,10 @@ def game(first, second, m, seed): migrated = [CSV_COLUMNS] for old_row in existing[1:]: values = dict(zip(old_header, old_row)) - migrated.append([values.get(column, "") for column in CSV_COLUMNS]) + migrated.append([ + values.get(column, values.get(LEGACY_COLUMN_ALIASES.get(column, ""), "")) + for column in CSV_COLUMNS + ]) tmp_path = results_path.with_suffix(".csv.tmp") with open(tmp_path, "w", newline="", encoding="utf-8") as f: csv.writer(f).writerows(migrated) @@ -259,19 +285,76 @@ def game(first, second, m, seed): cw.writerow(CSV_COLUMNS) cw.writerows(rows) +def wilson(wins, games, z=1.96): + """95% confidence interval for a win rate, as percentages.""" + if not games: + return 0.0, 100.0 + p = wins / games + d = 1 + z * z / games + centre = (p + z * z / (2 * games)) / d + half = z * sqrt(p * (1 - p) / games + z * z / (4 * games * games)) / d + return 100 * (centre - half), 100 * (centre + half) + + pct = 100 * w / n +lo, hi = wilson(w, n) print(f"\nGATE {CAND} vs {BASE} | {n} games | {CAND} wins {w} ({pct:.0f}%) " + f"[95% CI {lo:.0f}-{hi:.0f}%] " f"| core kills by {CAND}: {kills} | against: {kills_against}") if oddballs: print(f" note: {oddballs} draw/error games counted against {CAND}") +MAP_COL = CSV_COLUMNS.index("map") +SIDE_A_COL = CSV_COLUMNS.index("side_a") +WINNER_COL = CSV_COLUMNS.index("winner") + +# Split by seat, always. A map where the side-A seat (or the side-B seat) wins +# every game looks exactly like a balanced 50/50 map in the collapsed +# candidate-wins/total number, because the candidate holds each seat half the +# time. Reading such a map as "even" is how a seat artefact gets mistaken for +# real parity - print the split so the two cases can never be confused. per = {} +per_map_noise = False for r in rows: - per.setdefault(r[3], [0, 0]) - per[r[3]][1] += 1 - if r[6] == CAND: - per[r[3]][0] += 1 + stats = per.setdefault(r[MAP_COL], [0, 0, 0, 0, 0, 0]) + cand_is_a = r[SIDE_A_COL] == CAND + stats[1 if cand_is_a else 3] += 1 + if r[WINNER_COL] == CAND: + stats[0 if cand_is_a else 2] += 1 + # Decided games only. A crashed bot draws every game, which would otherwise + # read as "side B won every game" and dress a broken build up as a seat lock. + if r[WINNER_COL] in (CAND, BASE): + stats[5] += 1 + if r[WINNER_COL] == r[SIDE_A_COL]: + stats[4] += 1 for m in MAPS: - a, t = per.get(m, [0, 0]) - print(f" {m:12s} {a}/{t}") + wa, ga, wb, gb, a_seat_wins, decided = per.get(m, [0, 0, 0, 0, 0, 0]) + total = ga + gb + note = "" + if decided == total and total: + if a_seat_wins == total: + note = " SEAT-LOCKED (side A won every game)" + elif a_seat_wins == 0: + note = " SEAT-LOCKED (side B won every game)" + print(f" {m:12s} {wa + wb}/{total} as A {wa}/{ga} as B {wb}/{gb}{note}") + if total < 30: + per_map_noise = True + print("\nVERDICT:", "PROMOTE" if pct >= 55 else "REJECT", f"({pct:.0f}% vs the 55% bar)") + +# Measured 2026-08-01: three 168-game gates of a bot against a BYTE-IDENTICAL +# copy of itself scored 49%, 56%, 51% - the middle one clears the bar and +# PROMOTEs a bot over itself. The games are not reproducible because 46 of the +# 48 bots/ call random.* without ever seeding it; `fcode run --seed` seeds the +# engine, not the bot's Python RNG. (Proven: seeding it made 16/16 games +# byte-repeat, winner and turn count. Not the worker pool - serial reruns flip +# just as often - and not --tle, which flips at 50% even when disabled.) +# So a gate score is a sample, and the bar has to be read against its width. +if lo < 50 < hi: + print(f" WARNING: the 95% CI ({lo:.0f}-{hi:.0f}%) spans 50% - this result does not " + f"separate {CAND} from {BASE}.") + print(f" At n={n}, two identical bots land this far apart routinely. " + f"Raise seeds before reading anything into it.") +if per_map_noise: + print(" note: per-map rows are 8-16 games each (SD ~13-18 points). Treat them " + "as direction only, never as evidence for a map-specific change.") diff --git a/heal_audit.py b/heal_audit.py new file mode 100644 index 0000000..06862ca --- /dev/null +++ b/heal_audit.py @@ -0,0 +1,54 @@ +"""Core damage vs core healing per side, straight from a local .replay26. + +The ladder loss signature (2026-08-01) is siege damage that gets fully healed: + python heal_audit.py replays/*.replay26 +prints, per side, total core damage taken and how much of it was healed back. +A heal fraction near 1.0 means our siege converted to nothing. Works offline - +unlike live_replay_audit.py it needs no API metadata.""" +import sys +sys.path.insert(0,"/mnt/c/Users/subodh/Downloads/fcode") +from replay_stats import walk, pos_of, signed + +def analyse(path): + top = walk(open(path,"rb").read()) + ent = {} + mp = walk(top[(1,'m')][0]) or {} + for cm in mp.get((4,'m'), []): + c = walk(cm) or {} + cid = c.get((1,'v'),[None])[0] + if cid is not None: + ent[cid] = {"team": c.get((2,'v'),[0])[0], "type":"core"} + dmg = {0:0, 1:0}; heal = {0:0, 1:0}; guns = {0:0, 1:0} + turns = top[(3,'m')] + for tm in turns: + t = walk(tm) or {} + for e in t.get((1,'m'), []): + se = walk(e) or {} + if (1,'m') in se: + outer = walk(se[(1,'m')][0]) or {} + c = walk(outer[(1,'m')][0]) if (1,'m') in outer else outer + c = c or {} + cid = c.get((1,'v'),[None])[0] + team = c.get((2,'v'),[0])[0] + if cid is not None and cid not in ent: + ent[cid] = {"team": team, "type":"unit"} + if any(k[0]==21 for k in c): # gunner payload field + guns[team] = guns.get(team,0)+1 + elif (5,'m') in se: + d = walk(se[(5,'m')][0]) or {} + tid = d.get((1,'v'),[None])[0] + delta = signed(d.get((2,'v'),[0])[0]) + info = ent.get(tid) + if info and info["type"]=="core": + if delta < 0: dmg[info["team"]] += -delta + else: heal[info["team"]] += delta + return dmg, heal, guns, len(turns) + +for p in sys.argv[1:]: + dmg, heal, guns, n = analyse(p) + print(f"{p.split('/')[-1][:28]:30s} t{n:<5d}", end="") + for team in (0,1): + d,h = dmg[team], heal[team] + frac = h/d if d else 0 + print(f" | core{('A','B')[team]} took {d:5d} healed {h:5d} ({frac*100:3.0f}%)", end="") + print() diff --git a/tests/test_heal_lock_retreat.py b/tests/test_heal_lock_retreat.py new file mode 100644 index 0000000..fe090d0 --- /dev/null +++ b/tests/test_heal_lock_retreat.py @@ -0,0 +1,106 @@ +"""Deterministic tests for the heal-lock retreat. + +The trigger has to be a real "the siege stopped converting" signal, not a +one-off heal tick, and it has to release the unit again once the core starts +losing HP. These tests pin both directions plus the store round-trip. +""" + +from pathlib import Path +import importlib.util +import sys + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_bot(): + bot_dir = ROOT / "bots" / "exp_heal_lock_retreat" + sys.path.insert(0, str(bot_dir)) + try: + spec = importlib.util.spec_from_file_location( + "exp_heal_lock_retreat_main", bot_dir / "main.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +def test_store_round_trip(): + for hp in (0, 1, 500, 1000, 0xFFFF): + for evidence in range(BOT.HEAL_LOCK_MAX + 1): + packed = BOT.pack_heal_lock(hp, evidence) + assert packed >= 0 + if packed == 0: + # only the genuinely empty observation encodes as "unset" + assert hp == 0 and evidence == 0 + continue + assert BOT.unpack_heal_lock(packed) == (hp, evidence) + + +def test_unset_store_reads_as_no_observation(): + assert BOT.unpack_heal_lock(0) == (None, 0) + + +def test_first_observation_cannot_trigger(): + # No previous HP means no evidence, however low the core is. + assert BOT.next_heal_evidence(None, 120, 0) == 0 + + +def test_recovering_core_accumulates_evidence(): + evidence = 0 + evidence = BOT.next_heal_evidence(100, 120, evidence) + assert evidence == 1 + assert evidence < BOT.HEAL_LOCK_TRIGGER, "one heal tick must not trigger" + evidence = BOT.next_heal_evidence(120, 140, evidence) + assert evidence >= BOT.HEAL_LOCK_TRIGGER, "sustained recovery must trigger" + + +def test_converting_siege_releases_the_unit(): + evidence = BOT.HEAL_LOCK_TRIGGER + evidence = BOT.next_heal_evidence(140, 120, evidence) + assert evidence < BOT.HEAL_LOCK_TRIGGER, "damage getting through must release" + + +def test_evidence_is_bounded_both_ways(): + steps = BOT.HEAL_LOCK_MAX + 5 # more ticks than the cap, both ways + evidence = 0 + for i in range(steps): + evidence = BOT.next_heal_evidence(100 + 10 * i, 110 + 10 * i, evidence) + assert evidence == BOT.HEAL_LOCK_MAX + for i in range(steps): + evidence = BOT.next_heal_evidence(110 + 10 * i, 100 + 10 * i, evidence) + assert evidence == 0 + + +def test_stalemate_holds_evidence_steady(): + assert BOT.next_heal_evidence(120, 120, 1) == 1 + + +def test_heal_lock_slot_does_not_collide(): + used = { + BOT.SLOT_WALLER_ID, BOT.SLOT_SIEGER_1_ID, BOT.SLOT_SIEGER_2_ID, + BOT.SLOT_ENEMY_CORE, BOT.SLOT_SIEGER_2_COUNTER_TARGET, + BOT.SLOT_HOME_DEFENDER_ID, BOT.SLOT_HOME_THREAT, + } + # 0 is numSpawned, 1-6 the map share, 7 the initial target. + used.update({0, 1, 2, 3, 4, 5, 6, 7}) + assert BOT.SLOT_HEAL_LOCK not in used + assert 0 <= BOT.SLOT_HEAL_LOCK < 16 + + +def test_parent_behaviour_is_otherwise_untouched(): + """Everything the parent does outside the new branch must be identical.""" + import difflib + parent = (ROOT / "bots" / "generalist-v3" / "main.py").read_text( + encoding="utf-8").splitlines() + child = (ROOT / "bots" / "exp_heal_lock_retreat" / "main.py").read_text( + encoding="utf-8").splitlines() + removed = [ + line for line in difflib.unified_diff(parent, child, lineterm="", n=0) + if line.startswith("-") and not line.startswith("---") + ] + assert removed == [], f"the experiment deleted parent behaviour: {removed}" From f499f672b2e429fd54e0c7fd34f6791a526af61f Mon Sep 17 00:00:00 2001 From: Subodh Date: Sat, 1 Aug 2026 19:23:14 +0200 Subject: [PATCH 03/30] ship variant B (live v2) + the rush sparring partner Diagnosis, from 200 live games and 110 downloaded replays: the ladder's ranking function is how fast you get a gun onto the enemy core. Powered by SmartFridge lands its first at t19, Pantheon at t24, we managed t60. An econ probe showed titanium was NEVER the constraint - the core holds >120 Ti from round 0 and peaks at 470 by t45 with 5 builders - so a single constant was making us arrive last while sitting on unspent resources. exp_early_siege (variant A): SIEGE_START 45 -> 25. First gun t84 -> t58. 61% vs generalist-v3 at 336 games (CI 55-66, kills 183-111), 90% vs OogwayOld (control 86%), 93% vs lastpop2 (control 90%). exp_siege_on_sight (variant B): the gate becomes state, not a round number - enemy core located and titanium above the unchanged floor, both true from ~t5. 67% vs generalist-v3 at 336 games (CI 61-71, kills 211-91) and 63% head to head vs variant A. SHIPPED as live v2 at 17:20; v1 retained for rollback. spar_rush: sparring partner only, our engine with SIEGE_START = 5. Never submit it. Nothing in bots/ rushed, so no local gate could see how a candidate handles one - and it found a hole 500+ games against our own lineage hid: generalist-v3 scores 39% against it (kills 58-91) while beating our own lineage 86-93%. WORKFLOW.md now requires gating every candidate against it. Still open: gun-to-core conversion. Pantheon lands 89% of its guns within 6 tiles of the enemy core and 1% in its own half; we land roughly 30%. That is a bigger measured gap than the timing one this commit closes. Co-Authored-By: Claude Opus 5 --- WORKFLOW.md | 78 ++ bots/exp_early_siege/README.md | 20 + bots/exp_early_siege/initialSpawning.py | 120 ++ bots/exp_early_siege/main.py | 1182 +++++++++++++++++++ bots/exp_early_siege/mapPathfinding.py | 392 +++++++ bots/exp_early_siege/symmetry.py | 116 ++ bots/exp_siege_on_sight/README.md | 19 + bots/exp_siege_on_sight/initialSpawning.py | 120 ++ bots/exp_siege_on_sight/main.py | 1184 ++++++++++++++++++++ bots/exp_siege_on_sight/mapPathfinding.py | 392 +++++++ bots/exp_siege_on_sight/symmetry.py | 116 ++ bots/spar_rush/README.md | 16 + bots/spar_rush/initialSpawning.py | 120 ++ bots/spar_rush/main.py | 1180 +++++++++++++++++++ bots/spar_rush/mapPathfinding.py | 392 +++++++ bots/spar_rush/symmetry.py | 116 ++ siege-on-sight.zip | Bin 0 -> 18199 bytes tests/test_early_siege.py | 69 ++ 18 files changed, 5632 insertions(+) create mode 100644 bots/exp_early_siege/README.md create mode 100644 bots/exp_early_siege/initialSpawning.py create mode 100644 bots/exp_early_siege/main.py create mode 100644 bots/exp_early_siege/mapPathfinding.py create mode 100644 bots/exp_early_siege/symmetry.py create mode 100644 bots/exp_siege_on_sight/README.md create mode 100644 bots/exp_siege_on_sight/initialSpawning.py create mode 100644 bots/exp_siege_on_sight/main.py create mode 100644 bots/exp_siege_on_sight/mapPathfinding.py create mode 100644 bots/exp_siege_on_sight/symmetry.py create mode 100644 bots/spar_rush/README.md create mode 100644 bots/spar_rush/initialSpawning.py create mode 100644 bots/spar_rush/main.py create mode 100644 bots/spar_rush/mapPathfinding.py create mode 100644 bots/spar_rush/symmetry.py create mode 100644 siege-on-sight.zip create mode 100644 tests/test_early_siege.py diff --git a/WORKFLOW.md b/WORKFLOW.md index 62762ec..c97cc48 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -109,6 +109,84 @@ Two of us measured the same matchup 26 points apart once (kfort vs krb, 72% vs 46%) — environment drift, not variance. Check setup first, argue strategy second. +### MANDATORY: gate against `bots/spar_rush` as well as the live bot + +`spar_rush` is our own engine with `SIEGE_START = 5` — a sparring partner, never +a submission. It exists because the ladder's top two (Powered by SmartFridge, +Pantheon) put a gun on the enemy core at t19-t24 and NOTHING in `bots/` did +anything remotely like it, so no local gate could see how a candidate handles a +rush. It found a hole 500+ games against our own lineage had completely hidden: + +| vs `spar_rush` | win rate | core kills | +|---|---|---| +| `generalist-v3` (the live bot) | **39%** (CI 32-46) | 58-91 | +| `exp_early_siege` | 49-52% (two runs) | 70-78 / 79-71 | + +A candidate that only ever fights our own lineage is being measured against an +archetype that no longer exists at the top of the ladder. Run both: + +``` +python gate.py generalist-v3 8 quiet # 336 games, the real bar +python gate.py spar_rush 4 quiet # does it survive a rush? +``` + +Keep a fresh parent control next to each - the numbers only mean something as a +difference. + +### Measured noise floor (identical-code control, 2026-08-01, engine 2.3.3) + +`bots/exp_emergency_countertrade` is byte-identical to `bots/generalist-v3` +(md5 `bf31fa04b650`). Gating it against itself measures the harness, since every +result is pure noise. Rerun this control after any engine bump — it is the only +way to know what a number means. + +- **Aggregate is well calibrated: 50% at n=168, 52% at n=336.** Trust the + headline verdict to about +/-2-3%. The 55% bar is sound. +- **Per-map lines are NOISE. Do not read them.** Identical code produced + `sweden` 1/8 and `longship` 6/8 in the same run. Any claim of the form + "we're weak on " taken from a gate needs its own dedicated experiment; + a per-map line at n=8 or n=16 supports nothing. +- **Seat bias is large and pool-wide.** With identical code the second player + won 197/336 = 59% overall. This aggregate is solid. +- **Seat-locked maps (verified by direct `fcode run`, BOTH argument orders, + 20 games each, identical code):** `duel`, `showdown`, `skerry`, `strait` all + went **20/20 to the second-listed bot**. `sprint` is balanced (9/20). These + four cannot distinguish closely matched bots at all. +- **DO NOT DERIVE PER-MAP SEAT NUMBERS FROM gate.py OUTPUT — its per-game + " first" label does not reflect actual play order.** It reported + `strait` as balanced 8/16 when direct runs show 20/20, and its 4-seed and + 8-seed logs contradicted each other. The AGGREGATE verdict is still sound + (identical code lands at 50-52%, which requires genuine side-alternation), + so the bug is in logging, not in what it plays. Measure seat effects with + scripts/seat_check.sh, never by parsing the gate log. +- Seat bias is **bounded by skill**: across a large gap (generalist-v3 vs + OogwayOld, 86-14) skill won 29/32 on the seat-locked maps. The seat only + decides between *closely matched* bots. + +**Consequence for iteration — this is the important part.** The changes we +actually test are small deltas, which IS the closely-matched regime. On roughly +half the pool those games are decided by seat, and because the gate plays both +sides they average to a forced ~50%. So the gate dilutes exactly the improvement +it is meant to detect: a real +3% edge can land in the 45-60% "unproven" band +and be discarded. When a change gates at 52-54%, that is NOT evidence it did +nothing — rerun it on the balanced maps before dropping it. + +Do not describe this as "RNG" or fix it with more seeds. On `duel` the seed is +inert; extra seeds just produce more identical games. + +### Choosing a reference opponent + +**`bots/champion` is stale (= `oogerebus3`) and must not be used as a default +reference.** Screened against it, ten bots scored 91-100% and then lost to the +live bot; `OogwayNEW` screened 83% and lost 21-79 over 168 games. The reference +inverted the ranking. Gate against the CURRENT LIVE BOT unless you have a +specific reason not to, and state which reference a number came from — a win +rate is meaningless without it. + +A cheap screen can only ELIMINATE (a bot at 0-25% is genuinely broken). It +cannot promote: identical code reads up to 67% at n=12. Never advance a bot on +screen numbers alone. + ## Promotion procedure 1. Pass the frozen-parent and all broad gates in the required cycle. diff --git a/bots/exp_early_siege/README.md b/bots/exp_early_siege/README.md new file mode 100644 index 0000000..a6d2c79 --- /dev/null +++ b/bots/exp_early_siege/README.md @@ -0,0 +1,20 @@ +# exp_early_siege (Variant A — superseded by exp_siege_on_sight) + +Parent: `bots/generalist-v3`. One constant: `SIEGE_START = 45 -> 25`. + +Ladder measurement 2026-08-01: the top two teams put a gun within 6 tiles of the +enemy core at t19 (Powered by SmartFridge) and t24 (Pantheon); we managed t60. +An econ probe showed titanium is NEVER the constraint — the core holds >120 Ti +from round 0 and peaks at 470 by t45 — so this constant alone was making us +arrive last while sitting on unspent resources. + +| gate (336 / 168 games) | result | core kills | +|---|---|---| +| vs generalist-v3 | 61% (CI 55-66) | 183-111 | +| vs OogwayOld | 90% (control 86%) | 150-9 | +| vs lastpop2 | 93% (control 90%) | 151-13 | +| vs spar_rush | 49-52% (two runs) | 70-78 / 79-71 | + +First gun on the enemy core moved t84 -> t58. Superseded by +`exp_siege_on_sight`, which beat this bot 63% head to head. +Tests: `tests/test_early_siege.py`. diff --git a/bots/exp_early_siege/initialSpawning.py b/bots/exp_early_siege/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_early_siege/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_early_siege/main.py b/bots/exp_early_siege/main.py new file mode 100644 index 0000000..fd981b6 --- /dev/null +++ b/bots/exp_early_siege/main.py @@ -0,0 +1,1182 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +# Ladder measurement 2026-08-01: the teams above us put a gun within 6 tiles of +# the enemy core at a median of t19 (Powered by SmartFridge) and t24 (Pantheon); +# we manage t60. An econ probe on the parent shows titanium is NEVER the +# constraint - the core holds >120 Ti from round 0 and peaks at 470 by t45 with +# 5 builders - so this constant alone was deciding that we arrive last while +# sitting on unspent resources. Nothing else changes: the same two siegers, the +# same seats, the same titanium floor on every build. +SIEGE_START = 25 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_early_siege/mapPathfinding.py b/bots/exp_early_siege/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_early_siege/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_early_siege/symmetry.py b/bots/exp_early_siege/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_early_siege/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_siege_on_sight/README.md b/bots/exp_siege_on_sight/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_siege_on_sight/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_siege_on_sight/initialSpawning.py b/bots/exp_siege_on_sight/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_siege_on_sight/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_siege_on_sight/main.py b/bots/exp_siege_on_sight/main.py new file mode 100644 index 0000000..c964d19 --- /dev/null +++ b/bots/exp_siege_on_sight/main.py @@ -0,0 +1,1184 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_siege_on_sight/mapPathfinding.py b/bots/exp_siege_on_sight/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_siege_on_sight/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_siege_on_sight/symmetry.py b/bots/exp_siege_on_sight/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_siege_on_sight/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/spar_rush/README.md b/bots/spar_rush/README.md new file mode 100644 index 0000000..5eeb3c9 --- /dev/null +++ b/bots/spar_rush/README.md @@ -0,0 +1,16 @@ +# spar_rush — SPARRING PARTNER, NEVER A SUBMISSION + +Our own engine with `SIEGE_START = 5`. Do not submit it, do not stack it. + +It exists because the ladder's top teams put a gun on the enemy core at t19-t24 +and nothing in `bots/` did anything remotely like it, so no local gate could see +how a candidate handles a rush. It immediately found a hole that 500+ games +against our own lineage had completely hidden: + +| vs spar_rush | win rate | core kills | +|---|---|---| +| generalist-v3 (live at the time) | **39%** (CI 32-46) | 58-91 | +| exp_early_siege | 49-52% | 70-78 | +| exp_siege_on_sight | 52% | 76-69 | + +See the mandatory-gate section in `WORKFLOW.md`. diff --git a/bots/spar_rush/initialSpawning.py b/bots/spar_rush/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/spar_rush/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/spar_rush/main.py b/bots/spar_rush/main.py new file mode 100644 index 0000000..90141b4 --- /dev/null +++ b/bots/spar_rush/main.py @@ -0,0 +1,1180 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +# SPARRING PARTNER, NOT A CANDIDATE. The ladder's top two put a gun on the +# enemy core at t19-t24 and no bot in bots/ does anything like it, so no local +# gate could see how we fare against a rush. This is the cheapest honest proxy: +# our own engine dispatched almost immediately. Do not submit it, do not stack +# it - it exists so early-defence claims can be measured instead of asserted. +SIEGE_START = 5 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/spar_rush/mapPathfinding.py b/bots/spar_rush/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/spar_rush/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/spar_rush/symmetry.py b/bots/spar_rush/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/spar_rush/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/siege-on-sight.zip b/siege-on-sight.zip new file mode 100644 index 0000000000000000000000000000000000000000..2bcff735a261917f39ebf45bd2ffd9063f5f2ef0 GIT binary patch literal 18199 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 JtG3hk*Pk$w+t2_2 literal 0 HcmV?d00001 diff --git a/tests/test_early_siege.py b/tests/test_early_siege.py new file mode 100644 index 0000000..8f9214a --- /dev/null +++ b/tests/test_early_siege.py @@ -0,0 +1,69 @@ +"""Deterministic tests for the early-siege variant. + +The whole hypothesis is one constant, so the tests exist to prove that it is +ONLY that constant — a one-line experiment that quietly changed something else +would make its gate result uninterpretable. +""" + +from pathlib import Path +import difflib +import importlib.util +import sys + + +ROOT = Path(__file__).resolve().parents[1] +PARENT = ROOT / "bots" / "generalist-v3" / "main.py" +CHILD = ROOT / "bots" / "exp_early_siege" / "main.py" + + +def load(bot_name, module_name): + bot_dir = ROOT / "bots" / bot_name + sys.path.insert(0, str(bot_dir)) + try: + spec = importlib.util.spec_from_file_location( + module_name, bot_dir / "main.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +CHILD_MOD = load("exp_early_siege", "exp_early_siege_main") +PARENT_MOD = load("generalist-v3", "generalist_v3_main") + + +def test_siege_starts_earlier(): + assert PARENT_MOD.SIEGE_START == 45 + assert CHILD_MOD.SIEGE_START == 25 + assert CHILD_MOD.SIEGE_START < PARENT_MOD.SIEGE_START + + +def test_no_other_constant_moved(): + """Titanium floors, wall duty and role slots must be untouched.""" + for name in ( + "SIEGE_TITANIUM_FLOOR", "HOME_DEFENDER_HOLD_ROUNDS", + "SLOT_WALLER_ID", "SLOT_SIEGER_1_ID", "SLOT_SIEGER_2_ID", + "SLOT_ENEMY_CORE", "SLOT_SIEGER_2_COUNTER_TARGET", + "SLOT_HOME_DEFENDER_ID", "SLOT_HOME_THREAT", + ): + assert getattr(CHILD_MOD, name) == getattr(PARENT_MOD, name), name + + +def test_titanium_floor_still_gates_every_build(): + """The siege got earlier, not cheaper.""" + assert CHILD_MOD.SIEGE_TITANIUM_FLOOR == 120 + source = CHILD.read_text(encoding="utf-8") + assert source.count("get_global_resources() > SIEGE_TITANIUM_FLOOR") == 2 + + +def test_only_the_constant_line_differs(): + parent = PARENT.read_text(encoding="utf-8").splitlines() + child = CHILD.read_text(encoding="utf-8").splitlines() + changed = [ + line for line in difflib.unified_diff(parent, child, lineterm="", n=0) + if line.startswith(("+", "-")) + and not line.startswith(("+++", "---")) + and not line[1:].lstrip().startswith("#") + ] + assert changed == ["-SIEGE_START = 45", "+SIEGE_START = 25"], changed From c189db2685bb978967902a54841e77da9bef6a80 Mon Sep 17 00:00:00 2001 From: Subodh Date: Sat, 1 Aug 2026 20:56:58 +0200 Subject: [PATCH 04/30] the delivery thesis: eight measured negatives and the architecture wall The kill budget is real: 500 HP core, 10 dmg/2 ammo per gunner shot, ammo 1:1 same-turn, so a kill is 200 Ti against a 500 bank and ~55 of Pantheon's 58 median turns are logistics rather than shooting. Every attempt to exploit that still lost, and the causes are all different: exp_core_pressure 46% (43% vs rush) - own-half guns are real defensive value exp_relay 33% - launcher road delayed the first gun t35 -> t58 strike-v1 27% - 100% placement, 1.9 guns/game, starved with no econ strike-mass 13% - same starvation with 12 builders spar_pantheon 49% (kills 79-79) - allocation gain, economy loss, cancel exp_siege3 1 building, 0 titanium collected, dead t62 THE WALL: role allocation and economy are the same knob here. Economy work only happens in the non-sieger branch, so every builder moved to delivery is one removed from income (2 siegers -> 4.6 guns + 4.0 harvesters; 3 -> 0 and 0; all -> 4.5 and 0). Pantheon gets 18.7 guns on the enemy core from an economy the same size as ours, so their builders farm AND deliver. Architectural, not a parameter. Engine facts banked: launch() throws a friendly builder 7 tiles from 1-2 tiles away on a 1-turn cooldown for 20 Ti, so launchers ~6 apart give a hop per turn - we build zero, Pantheon builds zero, SmartFridge builds ~1 at t17. Ammo is 1:1 and same-turn. Whether enemy builders can be launched is UNRESOLVED; the probe's friendly control also failed, so it proves nothing. exp_siege_on_sight (live v2) remains the best bot we have. Co-Authored-By: Claude Opus 5 --- MEASUREMENT-FINDINGS.md | 38 + bots/exp_core_pressure/README.md | 19 + bots/exp_core_pressure/initialSpawning.py | 120 ++ bots/exp_core_pressure/main.py | 1208 ++++++++++++++++++++ bots/exp_core_pressure/mapPathfinding.py | 392 +++++++ bots/exp_core_pressure/symmetry.py | 116 ++ bots/exp_relay/README.md | 19 + bots/exp_relay/initialSpawning.py | 120 ++ bots/exp_relay/main.py | 1258 +++++++++++++++++++++ bots/exp_relay/mapPathfinding.py | 392 +++++++ bots/exp_relay/symmetry.py | 116 ++ bots/exp_siege3/README.md | 19 + bots/exp_siege3/initialSpawning.py | 120 ++ bots/exp_siege3/main.py | 1185 +++++++++++++++++++ bots/exp_siege3/mapPathfinding.py | 392 +++++++ bots/exp_siege3/symmetry.py | 116 ++ bots/exp_swarm/README.md | 19 + bots/exp_swarm/initialSpawning.py | 120 ++ bots/exp_swarm/main.py | 1200 ++++++++++++++++++++ bots/exp_swarm/mapPathfinding.py | 392 +++++++ bots/exp_swarm/symmetry.py | 116 ++ bots/spar_pantheon/README.md | 25 + bots/spar_pantheon/initialSpawning.py | 120 ++ bots/spar_pantheon/main.py | 1188 +++++++++++++++++++ bots/spar_pantheon/mapPathfinding.py | 392 +++++++ bots/spar_pantheon/symmetry.py | 116 ++ bots/strike-econ/initialSpawning.py | 120 ++ bots/strike-econ/main.py | 287 +++++ bots/strike-econ/mapPathfinding.py | 392 +++++++ bots/strike-econ/symmetry.py | 116 ++ bots/strike-mass/initialSpawning.py | 120 ++ bots/strike-mass/main.py | 254 +++++ bots/strike-mass/mapPathfinding.py | 392 +++++++ bots/strike-mass/symmetry.py | 116 ++ bots/strike-v1/initialSpawning.py | 120 ++ bots/strike-v1/main.py | 254 +++++ bots/strike-v1/mapPathfinding.py | 392 +++++++ bots/strike-v1/symmetry.py | 116 ++ 38 files changed, 11997 insertions(+) create mode 100644 bots/exp_core_pressure/README.md create mode 100644 bots/exp_core_pressure/initialSpawning.py create mode 100644 bots/exp_core_pressure/main.py create mode 100644 bots/exp_core_pressure/mapPathfinding.py create mode 100644 bots/exp_core_pressure/symmetry.py create mode 100644 bots/exp_relay/README.md create mode 100644 bots/exp_relay/initialSpawning.py create mode 100644 bots/exp_relay/main.py create mode 100644 bots/exp_relay/mapPathfinding.py create mode 100644 bots/exp_relay/symmetry.py create mode 100644 bots/exp_siege3/README.md create mode 100644 bots/exp_siege3/initialSpawning.py create mode 100644 bots/exp_siege3/main.py create mode 100644 bots/exp_siege3/mapPathfinding.py create mode 100644 bots/exp_siege3/symmetry.py create mode 100644 bots/exp_swarm/README.md create mode 100644 bots/exp_swarm/initialSpawning.py create mode 100644 bots/exp_swarm/main.py create mode 100644 bots/exp_swarm/mapPathfinding.py create mode 100644 bots/exp_swarm/symmetry.py create mode 100644 bots/spar_pantheon/README.md create mode 100644 bots/spar_pantheon/initialSpawning.py create mode 100644 bots/spar_pantheon/main.py create mode 100644 bots/spar_pantheon/mapPathfinding.py create mode 100644 bots/spar_pantheon/symmetry.py create mode 100644 bots/strike-econ/initialSpawning.py create mode 100644 bots/strike-econ/main.py create mode 100644 bots/strike-econ/mapPathfinding.py create mode 100644 bots/strike-econ/symmetry.py create mode 100644 bots/strike-mass/initialSpawning.py create mode 100644 bots/strike-mass/main.py create mode 100644 bots/strike-mass/mapPathfinding.py create mode 100644 bots/strike-mass/symmetry.py create mode 100644 bots/strike-v1/initialSpawning.py create mode 100644 bots/strike-v1/main.py create mode 100644 bots/strike-v1/mapPathfinding.py create mode 100644 bots/strike-v1/symmetry.py diff --git a/MEASUREMENT-FINDINGS.md b/MEASUREMENT-FINDINGS.md index 6512bec..c73a660 100644 --- a/MEASUREMENT-FINDINGS.md +++ b/MEASUREMENT-FINDINGS.md @@ -165,6 +165,44 @@ The MECHANISM is locally reproducible even though the win rate is not: mirror games hit the same heal-lock (`sprint` t350, core B took 490 and healed 490 = 100%). Measure it with `heal_audit.py` (new, offline, no API needed). +## The delivery thesis: eight experiments, all rejected, one architecture wall + +The kill budget from GameConstants: core 500 HP, gunner 10 dmg / 2 ammo on a +1-turn cooldown, ammo converts 1:1 once per team per turn and is usable the same +turn. A kill is 50 shots = 100 ammo + 10 guns = **200 Ti against a 500 bank**. +Ten guns in the band end a game in 5 turns of fire; Pantheon's median game is 58 +turns, so ~55 of them are logistics. That framing is right, and every attempt to +exploit it still failed. What each one cost us is the useful part: + +| experiment | vs shipped B | cause | +|---|---|---| +| `exp_core_pressure` (gun placement radius) | 46%, 43% vs rush | own-half guns ARE defensive value | +| `exp_relay` (launcher road, grafted) | 33% | road delayed first gun t35 -> t58 | +| `strike-v1` (delivery-first, no econ) | 27% | 100% placement but only 1.9 guns - starved | +| `strike-mass` (12 builders, no econ) | 13% | same starvation, worse | +| `strike-econ` (lean supply line) | not gated | harvester code never fired: store slot 1 collides with the map-share channel, and beeline paths never pass ore | +| `spar_pantheon` (all siege + damped econ) | 49%, kills 79-79 | allocation gain and economy loss cancel exactly | +| `exp_swarm` (all siege, econ untouched) | - | harvesters still went to 0.0: econ lives in the non-sieger branch | +| `exp_siege3` (three siegers) | - | 1 building, 0 titanium collected, dead t62 | + +**The wall: role allocation and economy are the same knob.** Economy work happens +only in the non-sieger branch, so every builder moved to delivery is a builder +removed from income. 2 siegers -> 4.6 guns + 4.0 harvesters; 3 siegers -> 0 and +0; all siegers -> 4.5 and 0. Pantheon gets 18.7 guns on the enemy core from an +economy the same size as ours (4.1 harvesters), which means their builders farm +AND deliver. That is architectural, not a parameter, and no variant of this +engine reached it. + +Verified engine facts worth keeping regardless: +- `launch(bot_pos, target)` throws a friendly builder: pickup range 1-2 tiles, + throw up to 7, 1-turn cooldown, 20 Ti base (cost scales per launcher). + Launchers spaced ~6 apart give one hop per turn. WE BUILD ZERO; so does + Pantheon (0 in 25 games); SmartFridge builds ~1 at t17-18 mid-map. +- Ammo is 1:1 with titanium, once per team per turn, usable immediately. +- Whether an ENEMY builder can be launched is UNRESOLVED - the probe returned 0 + legal targets for enemies, but also 0 for the friendly control in the same + run, so it proves nothing. + ## Open lead for generalist-v3 Action order follows entity id, and id is assigned at spawn — so **spawn timing diff --git a/bots/exp_core_pressure/README.md b/bots/exp_core_pressure/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_core_pressure/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_core_pressure/initialSpawning.py b/bots/exp_core_pressure/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_core_pressure/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_core_pressure/main.py b/bots/exp_core_pressure/main.py new file mode 100644 index 0000000..9c81d6e --- /dev/null +++ b/bots/exp_core_pressure/main.py @@ -0,0 +1,1208 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# Measured 2026-08-01 from live replays, gunner placement by distance to the +# ENEMY core: Pantheon (94% form) puts 70% of its guns in the 4-6 firing band, +# 19% adjacent to the core, and ONE PERCENT in its own half. We put 41% in our +# own half and only 29% within 6. A gun that cannot reach their core is 10 Ti +# and a builder action spent on nothing - the discretionary attack state builds +# wherever the builder happens to be standing, aimed at whatever wandered past. +# 12 is the outer edge of Pantheon's distribution: 99% of their guns are inside +# it. Dedicated home defence is NOT affected - that runs through +# _run_home_defense and its own planner, which this does not touch. +CORE_PRESSURE_RADIUS = 12 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def _pressures_enemy_core(self, seat: Position) -> bool: + """Is a gun on this tile close enough to their core to ever matter? + + Unknown core (early rounds, no symmetry lock yet) returns True - with no + information we keep the parent's behaviour rather than guess. + """ + target = self._enemy_core_target() + if target is None: + return True + return (abs(seat.x - target.x) + + abs(seat.y - target.y)) <= CORE_PRESSURE_RADIUS + + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + if not self._pressures_enemy_core(gunnerSpot): + continue # not worth 10 Ti out here; try another seat + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_core_pressure/mapPathfinding.py b/bots/exp_core_pressure/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_core_pressure/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_core_pressure/symmetry.py b/bots/exp_core_pressure/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_core_pressure/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_relay/README.md b/bots/exp_relay/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_relay/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_relay/initialSpawning.py b/bots/exp_relay/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_relay/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_relay/main.py b/bots/exp_relay/main.py new file mode 100644 index 0000000..71d61be --- /dev/null +++ b/bots/exp_relay/main.py @@ -0,0 +1,1258 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# THE ATTACK ROUTE. Measured against the engine, not inferred: a launcher picks +# up a friendly builder standing 1-2 tiles away and throws it up to 7 tiles, on +# a 1-turn cooldown, for 20 Ti base. Guns only matter within ~6 of the enemy +# core, and our siegers WALK 25-30 tiles to get there - that transit, not the +# dispatch time, is what caps our first gun at t52. Launchers spaced ~6 apart +# turn the trip into one turn per hop, and the chain is permanent: every +# builder after the first rides it. The core sits on ~470 unspent Ti at t45, +# so a 4-launcher route is already paid for. +RELAY_SPACING = 6 # launchers this far apart -> each throw lands in pickup range +RELAY_MIN_GAIN = 3 # only throw if it advances the bot at least this far +RELAY_TITANIUM_FLOOR = 200 # never spend the siege's gun money on road +RELAY_MAX = 5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder standing next to us toward their core. + + No coordination and no store slot: a builder that wanders into pickup + range gets flung forward, so the chain works for every unit that walks + past it, including ones spawned much later. + """ + target = self._enemy_core_target() + if target is None: + return + me = ct.get_position() + my_team = ct.get_team() + tiles = ct.get_attackable_tiles_from( + me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != my_team + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best = None + best_d = here - RELAY_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < best_d and ct.can_launch(bot, t): + best, best_d = t, d + if best is not None: + ct.launch(bot, best) + return + + def _lay_relay(self, ct: Controller, myLoc: Position, target: Position) -> bool: + """Drop one launcher if we are on the road and none is near enough.""" + if ct.get_global_resources() <= RELAY_TITANIUM_FLOOR: + return False + if not ct.can_act(): + return False + to_core = abs(myLoc.x - target.x) + abs(myLoc.y - target.y) + if to_core <= RELAY_SPACING: + return False # close enough to just build the gun + built = 0 + for uid in ct.get_nearby_buildings(): + if (ct.get_team(uid) == ct.get_team() + and ct.get_entity_type(uid) == EntityType.LAUNCHER): + lp = ct.get_position(uid) + built += 1 + if abs(lp.x - myLoc.x) + abs(lp.y - myLoc.y) < RELAY_SPACING: + return False # one already covers this stretch + if built >= RELAY_MAX: + return False + for d in CARDINALS: + spot = myLoc.add(d) + if ct.can_build_launcher(spot): + ct.build_launcher(spot) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + if self._lay_relay(ct, myLoc, target): + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_relay/mapPathfinding.py b/bots/exp_relay/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_relay/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_relay/symmetry.py b/bots/exp_relay/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_relay/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_siege3/README.md b/bots/exp_siege3/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_siege3/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_siege3/initialSpawning.py b/bots/exp_siege3/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_siege3/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_siege3/main.py b/bots/exp_siege3/main.py new file mode 100644 index 0000000..37bba23 --- /dev/null +++ b/bots/exp_siege3/main.py @@ -0,0 +1,1185 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_SIEGER_3_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_siege3/mapPathfinding.py b/bots/exp_siege3/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_siege3/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_siege3/symmetry.py b/bots/exp_siege3/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_siege3/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_swarm/README.md b/bots/exp_swarm/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_swarm/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_swarm/initialSpawning.py b/bots/exp_swarm/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_swarm/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_swarm/main.py b/bots/exp_swarm/main.py new file mode 100644 index 0000000..91d6a31 --- /dev/null +++ b/bots/exp_swarm/main.py @@ -0,0 +1,1200 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + """Every builder delivers guns, not just the two designated ones. + + ISOLATION TEST. spar_pantheon combined this with damped economy states + and landed at exactly 49% (kills 79-79) - dead even with the shipped + bot. Those two changes may simply cancel: allocation helping, starved + economy hurting. This changes ONLY the allocation, so the economy stays + at the shipped bot's 4.0 harvesters / 25 conveyors, which already + matches Pantheon's 4.1 / 17.7. + + The gap this targets: Pantheon converts an equivalent supply line into + 18.7 guns on the enemy core per game. We manage 4.6, with two siegers + doing all the delivering while everyone else farms forever. + """ + return True + + def _is_sieger_designated(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_swarm/mapPathfinding.py b/bots/exp_swarm/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_swarm/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_swarm/symmetry.py b/bots/exp_swarm/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_swarm/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/spar_pantheon/README.md b/bots/spar_pantheon/README.md new file mode 100644 index 0000000..a8cd379 --- /dev/null +++ b/bots/spar_pantheon/README.md @@ -0,0 +1,25 @@ +# generalist-v3 + +Live-v89 source plus one bounded emergency countertrade: + +- activates only with at least two visible turrets whose real attack patterns + hit the core; +- keeps the responder's builder stand outside every visible attack pattern; +- permits a countergun seat covered by at most one turret; +- waives the 120-Ti reserve only for that one trade. + +No opening, economy, spawning, siege, role, store, map, or opponent-specific +logic changed. + +Dev29 gates (168 games each): + +| opponent | wins | core kills | +|---|---:|---:| +| generalist-v2 | 90 (54%) | 79-69 | +| champion | 135 (80%) | 120-21 | +| lastpop2 | 153 (91%) | 147-14 | +| OogwayOld | 146 (87%) | 143-15 | + +Mechanism trace: 4 completed countergun builds from 20 plans against champion. +Second-family builds were not reproduced locally, so inactive live validation +is still required. See `generalist_v3_results.json` and `HANDOFF.md`. diff --git a/bots/spar_pantheon/initialSpawning.py b/bots/spar_pantheon/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/spar_pantheon/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/spar_pantheon/main.py b/bots/spar_pantheon/main.py new file mode 100644 index 0000000..4c685fb --- /dev/null +++ b/bots/spar_pantheon/main.py @@ -0,0 +1,1188 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + # SPARRING PROFILE: everyone sieges. Pantheon puts 89% of its guns + # within 6 of the enemy core and 1% in its own half; two designated + # siegers cannot produce that distribution. + return True + + def _is_sieger_unused(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # SPARRING PROFILE: Pantheon runs ~18 conveyors and ~4 harvesters to our + # ~94 and ~6.5. Economy states are damped, not removed - it still needs + # income to pay for guns. + harvestScore *= 0.85 + routeScore *= 0.45 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/spar_pantheon/mapPathfinding.py b/bots/spar_pantheon/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/spar_pantheon/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/spar_pantheon/symmetry.py b/bots/spar_pantheon/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/spar_pantheon/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/strike-econ/initialSpawning.py b/bots/strike-econ/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/strike-econ/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/strike-econ/main.py b/bots/strike-econ/main.py new file mode 100644 index 0000000..fab27f1 --- /dev/null +++ b/bots/strike-econ/main.py @@ -0,0 +1,287 @@ +"""STRIKE v1 - a delivery-first bot. + +The budget, straight from GameConstants: a core has 500 HP, a gunner does 10 +damage on a 1-turn cooldown for 2 ammo, and ammo converts 1:1 from titanium, +once per team per turn, usable the same turn. So a kill is 50 shots = 100 ammo += 100 Ti, plus 10 gunners at 10 Ti = 100 Ti. TWO HUNDRED TITANIUM against the +500 every core starts with. + +The whole kill is funded at turn zero. Ten guns in the firing band end a game in +five turns of fire; Pantheon's median game is 58 turns and only ~3 of those are +shooting. The other 55 are walking. This is a LOGISTICS problem, and every bot +in bots/ - ours worst of all - plays it as an economy problem: we lay 94 +conveyors and 6.5 harvesters before landing 2.6 guns anywhere that matters. + +So this bot has no economic opening. It spends the starting bank on builders, +walks them straight at the enemy core, and lays a launcher relay on the way: a +launcher throws a friendly builder standing 1-2 tiles away up to 7 tiles on a +1-turn cooldown, so spacing them ~6 apart turns a 30-turn walk into a few hops, +and the road then serves every builder that follows. + +Chassis is deliberately NOT from scratch - pathfinding, symmetry and the +core-seat firing checks are the proven generalist-v3 modules. bastion failed +because a new thesis on a new engine loses before the thesis can matter. Only +the POLICY here is new. + +MEASURED CAVEAT: grafting the same relay onto exp_siege_on_sight scored 33% +(CI 28-38) because road-building delayed the first gun t35 -> t58. Volume alone +did not pay. This bot only makes sense if the road is the plan from round 1 +rather than a tax on a siege that already works. +""" +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker +from mapPathfinding import * + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +SLOT_ENEMY_CORE = 11 + +RELAY_SPACING = 6 # a throw from one launcher lands in the next one's pickup range +RELAY_MIN_GAIN = 3 # never spend a throw on a trivial advance +RELAY_MAX = 4 +RELAY_RESERVE = 160 # keep guns + the ammo conversion funded before road +AMMO_TARGET = 120 # 100 ammo pays for a whole core; hold a margin +GUN_BAND = 6 # a gun outside this cannot reach their core +STRIKE_BUILDERS = 8 +# strike-v1 measured: perfect placement (15/15 guns on the enemy core) and a +# competitive first gun at t32, and it still lost at 27% because it only ever +# built 1.9 guns. It starved. The 500 bank funds the theoretical 200-Ti kill +# exactly once, against someone who never shoots back - guns die and must be +# replaced, and passive income is 10 Ti per 4 turns. +# Pantheon does NOT run zero economy: 4.1 harvesters and 17.7 conveyors, a lean +# supply line feeding continuous gun production. That is the level to hit - +# ours is 5.4 and 31.9, strike-v1 was 0 and 0. +HARVESTER_TARGET = 4 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + + # ---------------------------------------------------------------- core + + def runCore(self, ct: Controller) -> None: + titanium = ct.get_global_resources() + # Ammo IS the weapon: 1:1, once per turn, usable immediately. There is + # no reason to sit on titanium we are not about to spend on guns. + ammo = ct.get_global_ammo() + if ammo < AMMO_TARGET: + want = min(AMMO_TARGET - ammo, max(titanium - 150, 0)) + if want > 0 and ct.can_convert_ammo(want): + ct.convert_ammo(want) + self.numSpawned = ct.read_store(0) + if self.numSpawned < STRIKE_BUILDERS: + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + ct.spawn_builder(tile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) + return + + # ------------------------------------------------------------ launcher + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder that walks past, toward their core.""" + target = self._enemy_core_target() + if target is None: + return + me = ct.get_position() + my_team = ct.get_team() + tiles = ct.get_attackable_tiles_from( + me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != my_team + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best, best_d = None, here - RELAY_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < best_d and ct.can_launch(bot, t): + best, best_d = t, d + if best is not None: + ct.launch(bot, best) + return + + # ------------------------------------------------------------- builder + + def builderBot(self, ct: Controller) -> None: + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + target = self._enemy_core_target() + if target is None: + return + myLoc = ct.get_position() + dist = abs(myLoc.x - target.x) + abs(myLoc.y - target.y) + + # SUPPLY LINE FIRST, but only just enough of one. A builder standing on + # ore early takes it; nobody detours for it and nobody lays conveyor. + if self._take_nearby_ore(ct, myLoc): + return + + if dist <= GUN_BAND + 2: + if self._build_core_gun(ct, myLoc, target): + return + self.mapPf.moveTo(ct, target) + return + + if self._lay_relay(ct, myLoc, target): + return + self.mapPf.moveTo(ct, target) + + def _take_nearby_ore(self, ct: Controller, myLoc: Position) -> bool: + """Build a harvester if one is free right here. No detours, no chains.""" + if not ct.can_act(): + return False + built = ct.read_store(1) + if built >= HARVESTER_TARGET: + return False + for d in CARDINALS: + spot = myLoc.add(d) + if ct.get_tile_env(spot) != Environment.ORE_TITANIUM: + continue + if ct.get_tile_building_id(spot) is not None: + continue + if ct.can_build_harvester(spot): + ct.build_harvester(spot) + ct.write_store(1, built + 1) + return True + return False + + def _build_core_gun(self, ct: Controller, myLoc: Position, + target: Position) -> bool: + """Only seats that can actually fire on the core. No mid-map brawling.""" + if not ct.can_act(): + return False + core_tiles = [Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1)] + for d in CARDINALS: + seat = myLoc.add(d) + if not (0 <= seat.x < self.mapW and 0 <= seat.y < self.mapH): + continue + if ct.get_tile_building_id(seat) is not None: + continue + for facing in DIRECTIONS: + for core_tile in core_tiles: + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + continue + if ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + return True + return False + + def _lay_relay(self, ct: Controller, myLoc: Position, + target: Position) -> bool: + if ct.get_global_resources() <= RELAY_RESERVE or not ct.can_act(): + return False + built = 0 + for uid in ct.get_nearby_buildings(): + if (ct.get_team(uid) == ct.get_team() + and ct.get_entity_type(uid) == EntityType.LAUNCHER): + built += 1 + lp = ct.get_position(uid) + if abs(lp.x - myLoc.x) + abs(lp.y - myLoc.y) < RELAY_SPACING: + return False + if built >= RELAY_MAX: + return False + for d in CARDINALS: + spot = myLoc.add(d) + if ct.can_build_launcher(spot): + ct.build_launcher(spot) + return True + return False + + # ---------------------------------------------------------------- guns + + def runGunner(self, ct: Controller) -> None: + curTarget = ct.get_gunner_target() + if curTarget is not None: + tid = ct.get_tile_building_id(curTarget) + bb = ct.get_tile_builder_bot_id(curTarget) + if bb is not None and ct.get_team(bb) == ct.get_team(): + return + if tid is not None and ct.get_team(tid) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + myPos = ct.get_position() + myDir = ct.get_direction() + best, bestDir = 0, myDir + for d in DIRECTIONS: + score = 0 + for tile in ct.get_attackable_tiles_from( + myPos, d, EntityType.GUNNER): + tid = ct.get_tile_building_id(tile) + if tid is not None and ct.get_team(tid) != ct.get_team(): + t = ct.get_entity_type(tid) + score += 1000 if t == EntityType.CORE else 4 + if score > best: + best, bestDir = score, d + if bestDir != myDir and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + # -------------------------------------------------------------- shared + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + for bid in ct.get_nearby_buildings(): + if (ct.get_team(bid) != ct.get_team() + and ct.get_entity_type(bid) == EntityType.CORE): + self.enemyCorePos = ct.get_position(bid) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def run(self, ct: Controller) -> None: + # dev26+: an uncaught exception PERMANENTLY destroys the unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + except Exception: + pass diff --git a/bots/strike-econ/mapPathfinding.py b/bots/strike-econ/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/strike-econ/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/strike-econ/symmetry.py b/bots/strike-econ/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/strike-econ/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/strike-mass/initialSpawning.py b/bots/strike-mass/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/strike-mass/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/strike-mass/main.py b/bots/strike-mass/main.py new file mode 100644 index 0000000..6a460e9 --- /dev/null +++ b/bots/strike-mass/main.py @@ -0,0 +1,254 @@ +"""STRIKE v1 - a delivery-first bot. + +The budget, straight from GameConstants: a core has 500 HP, a gunner does 10 +damage on a 1-turn cooldown for 2 ammo, and ammo converts 1:1 from titanium, +once per team per turn, usable the same turn. So a kill is 50 shots = 100 ammo += 100 Ti, plus 10 gunners at 10 Ti = 100 Ti. TWO HUNDRED TITANIUM against the +500 every core starts with. + +The whole kill is funded at turn zero. Ten guns in the firing band end a game in +five turns of fire; Pantheon's median game is 58 turns and only ~3 of those are +shooting. The other 55 are walking. This is a LOGISTICS problem, and every bot +in bots/ - ours worst of all - plays it as an economy problem: we lay 94 +conveyors and 6.5 harvesters before landing 2.6 guns anywhere that matters. + +So this bot has no economic opening. It spends the starting bank on builders, +walks them straight at the enemy core, and lays a launcher relay on the way: a +launcher throws a friendly builder standing 1-2 tiles away up to 7 tiles on a +1-turn cooldown, so spacing them ~6 apart turns a 30-turn walk into a few hops, +and the road then serves every builder that follows. + +Chassis is deliberately NOT from scratch - pathfinding, symmetry and the +core-seat firing checks are the proven generalist-v3 modules. bastion failed +because a new thesis on a new engine loses before the thesis can matter. Only +the POLICY here is new. + +MEASURED CAVEAT: grafting the same relay onto exp_siege_on_sight scored 33% +(CI 28-38) because road-building delayed the first gun t35 -> t58. Volume alone +did not pay. This bot only makes sense if the road is the plan from round 1 +rather than a tax on a siege that already works. +""" +from fcode import Controller, Direction, EntityType, Position +from symmetry import SymmetryTracker +from mapPathfinding import * + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +SLOT_ENEMY_CORE = 11 + +RELAY_SPACING = 6 # a throw from one launcher lands in the next one's pickup range +RELAY_MIN_GAIN = 3 # never spend a throw on a trivial advance +RELAY_MAX = 0 +RELAY_RESERVE = 160 # keep guns + the ammo conversion funded before road +AMMO_TARGET = 120 # 100 ammo pays for a whole core; hold a margin +GUN_BAND = 6 # a gun outside this cannot reach their core +STRIKE_BUILDERS = 12 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + + # ---------------------------------------------------------------- core + + def runCore(self, ct: Controller) -> None: + titanium = ct.get_global_resources() + # Ammo IS the weapon: 1:1, once per turn, usable immediately. There is + # no reason to sit on titanium we are not about to spend on guns. + ammo = ct.get_global_ammo() + if ammo < AMMO_TARGET: + want = min(AMMO_TARGET - ammo, max(titanium - 150, 0)) + if want > 0 and ct.can_convert_ammo(want): + ct.convert_ammo(want) + self.numSpawned = ct.read_store(0) + if self.numSpawned < STRIKE_BUILDERS: + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + ct.spawn_builder(tile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) + return + + # ------------------------------------------------------------ launcher + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder that walks past, toward their core.""" + target = self._enemy_core_target() + if target is None: + return + me = ct.get_position() + my_team = ct.get_team() + tiles = ct.get_attackable_tiles_from( + me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != my_team + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best, best_d = None, here - RELAY_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < best_d and ct.can_launch(bot, t): + best, best_d = t, d + if best is not None: + ct.launch(bot, best) + return + + # ------------------------------------------------------------- builder + + def builderBot(self, ct: Controller) -> None: + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + target = self._enemy_core_target() + if target is None: + return + myLoc = ct.get_position() + dist = abs(myLoc.x - target.x) + abs(myLoc.y - target.y) + + if dist <= GUN_BAND + 2: + if self._build_core_gun(ct, myLoc, target): + return + self.mapPf.moveTo(ct, target) + return + + if self._lay_relay(ct, myLoc, target): + return + self.mapPf.moveTo(ct, target) + + def _build_core_gun(self, ct: Controller, myLoc: Position, + target: Position) -> bool: + """Only seats that can actually fire on the core. No mid-map brawling.""" + if not ct.can_act(): + return False + core_tiles = [Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1)] + for d in CARDINALS: + seat = myLoc.add(d) + if not (0 <= seat.x < self.mapW and 0 <= seat.y < self.mapH): + continue + if ct.get_tile_building_id(seat) is not None: + continue + for facing in DIRECTIONS: + for core_tile in core_tiles: + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + continue + if ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + return True + return False + + def _lay_relay(self, ct: Controller, myLoc: Position, + target: Position) -> bool: + if ct.get_global_resources() <= RELAY_RESERVE or not ct.can_act(): + return False + built = 0 + for uid in ct.get_nearby_buildings(): + if (ct.get_team(uid) == ct.get_team() + and ct.get_entity_type(uid) == EntityType.LAUNCHER): + built += 1 + lp = ct.get_position(uid) + if abs(lp.x - myLoc.x) + abs(lp.y - myLoc.y) < RELAY_SPACING: + return False + if built >= RELAY_MAX: + return False + for d in CARDINALS: + spot = myLoc.add(d) + if ct.can_build_launcher(spot): + ct.build_launcher(spot) + return True + return False + + # ---------------------------------------------------------------- guns + + def runGunner(self, ct: Controller) -> None: + curTarget = ct.get_gunner_target() + if curTarget is not None: + tid = ct.get_tile_building_id(curTarget) + bb = ct.get_tile_builder_bot_id(curTarget) + if bb is not None and ct.get_team(bb) == ct.get_team(): + return + if tid is not None and ct.get_team(tid) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + myPos = ct.get_position() + myDir = ct.get_direction() + best, bestDir = 0, myDir + for d in DIRECTIONS: + score = 0 + for tile in ct.get_attackable_tiles_from( + myPos, d, EntityType.GUNNER): + tid = ct.get_tile_building_id(tile) + if tid is not None and ct.get_team(tid) != ct.get_team(): + t = ct.get_entity_type(tid) + score += 1000 if t == EntityType.CORE else 4 + if score > best: + best, bestDir = score, d + if bestDir != myDir and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + # -------------------------------------------------------------- shared + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + for bid in ct.get_nearby_buildings(): + if (ct.get_team(bid) != ct.get_team() + and ct.get_entity_type(bid) == EntityType.CORE): + self.enemyCorePos = ct.get_position(bid) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def run(self, ct: Controller) -> None: + # dev26+: an uncaught exception PERMANENTLY destroys the unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + except Exception: + pass diff --git a/bots/strike-mass/mapPathfinding.py b/bots/strike-mass/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/strike-mass/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/strike-mass/symmetry.py b/bots/strike-mass/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/strike-mass/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/strike-v1/initialSpawning.py b/bots/strike-v1/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/strike-v1/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/strike-v1/main.py b/bots/strike-v1/main.py new file mode 100644 index 0000000..c206109 --- /dev/null +++ b/bots/strike-v1/main.py @@ -0,0 +1,254 @@ +"""STRIKE v1 - a delivery-first bot. + +The budget, straight from GameConstants: a core has 500 HP, a gunner does 10 +damage on a 1-turn cooldown for 2 ammo, and ammo converts 1:1 from titanium, +once per team per turn, usable the same turn. So a kill is 50 shots = 100 ammo += 100 Ti, plus 10 gunners at 10 Ti = 100 Ti. TWO HUNDRED TITANIUM against the +500 every core starts with. + +The whole kill is funded at turn zero. Ten guns in the firing band end a game in +five turns of fire; Pantheon's median game is 58 turns and only ~3 of those are +shooting. The other 55 are walking. This is a LOGISTICS problem, and every bot +in bots/ - ours worst of all - plays it as an economy problem: we lay 94 +conveyors and 6.5 harvesters before landing 2.6 guns anywhere that matters. + +So this bot has no economic opening. It spends the starting bank on builders, +walks them straight at the enemy core, and lays a launcher relay on the way: a +launcher throws a friendly builder standing 1-2 tiles away up to 7 tiles on a +1-turn cooldown, so spacing them ~6 apart turns a 30-turn walk into a few hops, +and the road then serves every builder that follows. + +Chassis is deliberately NOT from scratch - pathfinding, symmetry and the +core-seat firing checks are the proven generalist-v3 modules. bastion failed +because a new thesis on a new engine loses before the thesis can matter. Only +the POLICY here is new. + +MEASURED CAVEAT: grafting the same relay onto exp_siege_on_sight scored 33% +(CI 28-38) because road-building delayed the first gun t35 -> t58. Volume alone +did not pay. This bot only makes sense if the road is the plan from round 1 +rather than a tax on a siege that already works. +""" +from fcode import Controller, Direction, EntityType, Position +from symmetry import SymmetryTracker +from mapPathfinding import * + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +SLOT_ENEMY_CORE = 11 + +RELAY_SPACING = 6 # a throw from one launcher lands in the next one's pickup range +RELAY_MIN_GAIN = 3 # never spend a throw on a trivial advance +RELAY_MAX = 4 +RELAY_RESERVE = 160 # keep guns + the ammo conversion funded before road +AMMO_TARGET = 120 # 100 ammo pays for a whole core; hold a margin +GUN_BAND = 6 # a gun outside this cannot reach their core +STRIKE_BUILDERS = 6 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + + # ---------------------------------------------------------------- core + + def runCore(self, ct: Controller) -> None: + titanium = ct.get_global_resources() + # Ammo IS the weapon: 1:1, once per turn, usable immediately. There is + # no reason to sit on titanium we are not about to spend on guns. + ammo = ct.get_global_ammo() + if ammo < AMMO_TARGET: + want = min(AMMO_TARGET - ammo, max(titanium - 150, 0)) + if want > 0 and ct.can_convert_ammo(want): + ct.convert_ammo(want) + self.numSpawned = ct.read_store(0) + if self.numSpawned < STRIKE_BUILDERS: + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + ct.spawn_builder(tile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) + return + + # ------------------------------------------------------------ launcher + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder that walks past, toward their core.""" + target = self._enemy_core_target() + if target is None: + return + me = ct.get_position() + my_team = ct.get_team() + tiles = ct.get_attackable_tiles_from( + me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != my_team + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best, best_d = None, here - RELAY_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < best_d and ct.can_launch(bot, t): + best, best_d = t, d + if best is not None: + ct.launch(bot, best) + return + + # ------------------------------------------------------------- builder + + def builderBot(self, ct: Controller) -> None: + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + target = self._enemy_core_target() + if target is None: + return + myLoc = ct.get_position() + dist = abs(myLoc.x - target.x) + abs(myLoc.y - target.y) + + if dist <= GUN_BAND + 2: + if self._build_core_gun(ct, myLoc, target): + return + self.mapPf.moveTo(ct, target) + return + + if self._lay_relay(ct, myLoc, target): + return + self.mapPf.moveTo(ct, target) + + def _build_core_gun(self, ct: Controller, myLoc: Position, + target: Position) -> bool: + """Only seats that can actually fire on the core. No mid-map brawling.""" + if not ct.can_act(): + return False + core_tiles = [Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1)] + for d in CARDINALS: + seat = myLoc.add(d) + if not (0 <= seat.x < self.mapW and 0 <= seat.y < self.mapH): + continue + if ct.get_tile_building_id(seat) is not None: + continue + for facing in DIRECTIONS: + for core_tile in core_tiles: + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + continue + if ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + return True + return False + + def _lay_relay(self, ct: Controller, myLoc: Position, + target: Position) -> bool: + if ct.get_global_resources() <= RELAY_RESERVE or not ct.can_act(): + return False + built = 0 + for uid in ct.get_nearby_buildings(): + if (ct.get_team(uid) == ct.get_team() + and ct.get_entity_type(uid) == EntityType.LAUNCHER): + built += 1 + lp = ct.get_position(uid) + if abs(lp.x - myLoc.x) + abs(lp.y - myLoc.y) < RELAY_SPACING: + return False + if built >= RELAY_MAX: + return False + for d in CARDINALS: + spot = myLoc.add(d) + if ct.can_build_launcher(spot): + ct.build_launcher(spot) + return True + return False + + # ---------------------------------------------------------------- guns + + def runGunner(self, ct: Controller) -> None: + curTarget = ct.get_gunner_target() + if curTarget is not None: + tid = ct.get_tile_building_id(curTarget) + bb = ct.get_tile_builder_bot_id(curTarget) + if bb is not None and ct.get_team(bb) == ct.get_team(): + return + if tid is not None and ct.get_team(tid) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + myPos = ct.get_position() + myDir = ct.get_direction() + best, bestDir = 0, myDir + for d in DIRECTIONS: + score = 0 + for tile in ct.get_attackable_tiles_from( + myPos, d, EntityType.GUNNER): + tid = ct.get_tile_building_id(tile) + if tid is not None and ct.get_team(tid) != ct.get_team(): + t = ct.get_entity_type(tid) + score += 1000 if t == EntityType.CORE else 4 + if score > best: + best, bestDir = score, d + if bestDir != myDir and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + # -------------------------------------------------------------- shared + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + for bid in ct.get_nearby_buildings(): + if (ct.get_team(bid) != ct.get_team() + and ct.get_entity_type(bid) == EntityType.CORE): + self.enemyCorePos = ct.get_position(bid) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def run(self, ct: Controller) -> None: + # dev26+: an uncaught exception PERMANENTLY destroys the unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + except Exception: + pass diff --git a/bots/strike-v1/mapPathfinding.py b/bots/strike-v1/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/strike-v1/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/strike-v1/symmetry.py b/bots/strike-v1/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/strike-v1/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out From 5fdfbb57f5b07bcb629c7616799003077258339a Mon Sep 17 00:00:00 2001 From: Subodh Date: Sat, 1 Aug 2026 21:21:20 +0200 Subject: [PATCH 05/30] five more measured negatives: the allocation wall is real exp_opportunist 48% - any builder near their core plants a core-capable gun. Economy preserved (4.2 harvesters) but on-core guns did not rise: economy builders never travel far enough for the opportunity to exist. exp_cadre - fixed 3-builder economy cadre, everyone later sieges, with a cheaper spawn threshold. 2.1 guns on core, 2.8 harvesters. A builder is 30 Ti = THREE gunners, so a marginal builder must deliver >3 guns to pay for itself. Population is not the lever either. exp_forward_armor 52% (CI 45-60, kills 76-71) - barriers at the siege seat when no firing seat is available. Pantheon builds ~40/game with 39% in the 4-6 band; we reached 1.2. The only variant above 50 with a positive kill differential, so it is being resolved at 336. exp_roadcrew - dedicated builder lays the launcher road so siegers never stop. Still slower to first gun (t45 vs t36) and only 1.4 of 4 launchers completed. That is the THIRD way of funding the relay to fail; the mechanic does not pay in this engine. Nothing has beaten exp_siege_on_sight (live v2). Thirteen experiments now agree that in this architecture a builder either farms or delivers and never both, and that titanium spent on builders is titanium not spent on guns. Pantheon gets 18.7 guns on the enemy core from a 4.1-harvester economy; we get 4.6 from 4.0. That difference is in how their bot is written, not in a setting reachable from here. Co-Authored-By: Claude Opus 5 --- bots/exp_cadre/README.md | 19 + bots/exp_cadre/initialSpawning.py | 120 ++ bots/exp_cadre/main.py | 1204 ++++++++++++++++++++ bots/exp_cadre/mapPathfinding.py | 392 +++++++ bots/exp_cadre/symmetry.py | 116 ++ bots/exp_forward_armor/README.md | 19 + bots/exp_forward_armor/initialSpawning.py | 120 ++ bots/exp_forward_armor/main.py | 1235 ++++++++++++++++++++ bots/exp_forward_armor/mapPathfinding.py | 392 +++++++ bots/exp_forward_armor/symmetry.py | 116 ++ bots/exp_opportunist/README.md | 19 + bots/exp_opportunist/initialSpawning.py | 120 ++ bots/exp_opportunist/main.py | 1221 ++++++++++++++++++++ bots/exp_opportunist/mapPathfinding.py | 392 +++++++ bots/exp_opportunist/symmetry.py | 116 ++ bots/exp_roadcrew/README.md | 19 + bots/exp_roadcrew/initialSpawning.py | 120 ++ bots/exp_roadcrew/main.py | 1263 +++++++++++++++++++++ bots/exp_roadcrew/mapPathfinding.py | 392 +++++++ bots/exp_roadcrew/symmetry.py | 116 ++ 20 files changed, 7511 insertions(+) create mode 100644 bots/exp_cadre/README.md create mode 100644 bots/exp_cadre/initialSpawning.py create mode 100644 bots/exp_cadre/main.py create mode 100644 bots/exp_cadre/mapPathfinding.py create mode 100644 bots/exp_cadre/symmetry.py create mode 100644 bots/exp_forward_armor/README.md create mode 100644 bots/exp_forward_armor/initialSpawning.py create mode 100644 bots/exp_forward_armor/main.py create mode 100644 bots/exp_forward_armor/mapPathfinding.py create mode 100644 bots/exp_forward_armor/symmetry.py create mode 100644 bots/exp_opportunist/README.md create mode 100644 bots/exp_opportunist/initialSpawning.py create mode 100644 bots/exp_opportunist/main.py create mode 100644 bots/exp_opportunist/mapPathfinding.py create mode 100644 bots/exp_opportunist/symmetry.py create mode 100644 bots/exp_roadcrew/README.md create mode 100644 bots/exp_roadcrew/initialSpawning.py create mode 100644 bots/exp_roadcrew/main.py create mode 100644 bots/exp_roadcrew/mapPathfinding.py create mode 100644 bots/exp_roadcrew/symmetry.py diff --git a/bots/exp_cadre/README.md b/bots/exp_cadre/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_cadre/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_cadre/initialSpawning.py b/bots/exp_cadre/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_cadre/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_cadre/main.py b/bots/exp_cadre/main.py new file mode 100644 index 0000000..01d70b7 --- /dev/null +++ b/bots/exp_cadre/main.py @@ -0,0 +1,1204 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_CUTOFF = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# Every allocation test so far held the builder POPULATION fixed and moved roles +# around, which always robbed income: 2 siegers -> 4.6 guns + 4.0 harvesters, +# 3 -> 0 and 0, all -> 4.5 and 0. Opportunistic delivery failed too, because +# economy builders never travel far enough to get the chance. +# So grow the population instead. A fixed economy CADRE (the waller plus the +# first two builders) keeps farming forever; every builder spawned after them is +# a sieger. Delivery then scales with income instead of competing with it. +# Builders cost 30 Ti and we sit on ~470 unspent at t45, so the spawn threshold +# is the thing actually capping our gun count. +CADRE_SIZE = 3 +SPAWN_BASE = 40 # was 80 + 60*n; builders are the cheapest gun delivery we have +SPAWN_STEP = 30 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + if self.numSpawned + 1 == CADRE_SIZE: + # ids only increase, so a threshold is a stable role test + # without needing a store slot per unit. + ct.write_store(SLOT_SIEGER_CUTOFF, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > SPAWN_BASE + SPAWN_STEP * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + if encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ): + return True + cutoff = ct.read_store(SLOT_SIEGER_CUTOFF) + return cutoff != 0 and encoded_id > cutoff + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_cadre/mapPathfinding.py b/bots/exp_cadre/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_cadre/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_cadre/symmetry.py b/bots/exp_cadre/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_cadre/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_forward_armor/README.md b/bots/exp_forward_armor/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_forward_armor/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_forward_armor/initialSpawning.py b/bots/exp_forward_armor/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_forward_armor/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_forward_armor/main.py b/bots/exp_forward_armor/main.py new file mode 100644 index 0000000..0487f52 --- /dev/null +++ b/bots/exp_forward_armor/main.py @@ -0,0 +1,1235 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# Pantheon lands 18.7 guns on the enemy core per game from an economy the same +# size as ours (4.1 harvesters); we land 4.6. Population, allocation, placement +# and transit were all tested and all failed, which leaves SURVIVAL: they build +# ~40 barriers a game and 39% of them sit in the 4-6 siege-seat band, armouring +# the forward position rather than home. A barrier is 3 Ti for 30 HP - three +# gunner shots - and per Oogway's own pathfinding, our own barriers are passable +# to us, so this costs no movement. +# NOT the aegis-v1 / bastion mechanism: those armoured HOME and were correctly +# rejected. This armours the sieger where it is actually being shot. +ARMOR_COST_FLOOR = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + # No seat we can shoot from - every one is occupied or covered. + # That is exactly when Pantheon puts 30 HP down instead, and it is + # the only moment a sieger has a spare action anyway. + if self._forward_armor(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _forward_armor(self, ct: Controller, myLoc: Position) -> bool: + """Put 30 HP between us and whatever is shooting the siege seat.""" + if not ct.can_act() or ct.get_global_resources() <= ARMOR_COST_FLOOR: + return False + my_team = ct.get_team() + threat = None + for bid in ct.get_nearby_buildings(): + if ct.get_team(bid) == my_team: + continue + btype = ct.get_entity_type(bid) + if btype not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + tiles = ct.get_attackable_tiles_from( + ct.get_position(bid), ct.get_direction(bid), btype) + if myLoc in tiles: + threat = ct.get_position(bid) + break + if threat is None: + return False + best, best_d = None, None + for d in CARDINALS: + spot = myLoc.add(d) + if ct.get_tile_building_id(spot) is not None: + continue + dist = abs(spot.x - threat.x) + abs(spot.y - threat.y) + if best_d is None or dist < best_d: + if ct.can_build_barrier(spot): + best, best_d = spot, dist + if best is not None: + ct.build_barrier(best) + return True + return False + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (not ct.can_build_gunner(seat, facing) + and self._forward_armor(ct, myLoc)): + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_forward_armor/mapPathfinding.py b/bots/exp_forward_armor/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_forward_armor/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_forward_armor/symmetry.py b/bots/exp_forward_armor/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_forward_armor/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_opportunist/README.md b/bots/exp_opportunist/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_opportunist/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_opportunist/initialSpawning.py b/bots/exp_opportunist/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_opportunist/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_opportunist/main.py b/bots/exp_opportunist/main.py new file mode 100644 index 0000000..aeb171c --- /dev/null +++ b/bots/exp_opportunist/main.py @@ -0,0 +1,1221 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# THE WALL, measured: role allocation and economy are the same knob. Economy +# work happens only in the non-sieger branch, so 2 siegers gives 4.6 guns on the +# enemy core and 4.0 harvesters, 3 siegers gives 0 and 0, and all-siege gives +# 4.5 and 0. Pantheon gets 18.7 guns from an economy the same size as ours, so +# their builders must farm AND deliver. +# This does not move anyone into the sieger role. It lets ANY builder that +# already happens to be near their core place a core-capable gun before falling +# through to its normal economy state. Delivery becomes opportunistic instead of +# exclusive, so nothing is taken away from income. +OPPORTUNIST_BAND = 8 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + if self._opportunist_gun(ct, myLoc): + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _opportunist_gun(self, ct: Controller, myLoc: Position) -> bool: + """Any builder standing near their core plants a core-capable gun. + + Reuses the sieger's own seat selection, so the seat still has to pass + can_fire_from() against a core tile - this cannot place the mid-map guns + that exp_core_pressure showed are worthless offensively. + """ + if not ct.can_act(): + return False + if ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + return False + target = self._enemy_core_target() + if target is None: + return False + if abs(myLoc.x - target.x) + abs(myLoc.y - target.y) > OPPORTUNIST_BAND: + return False + for plan in self._direct_siege_plans(ct, myLoc, target): + seat, stand, facing = plan[1:] + if stand != myLoc: + continue # do not divert; only build from where we are + if ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_opportunist/mapPathfinding.py b/bots/exp_opportunist/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_opportunist/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_opportunist/symmetry.py b/bots/exp_opportunist/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_opportunist/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_roadcrew/README.md b/bots/exp_roadcrew/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_roadcrew/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_roadcrew/initialSpawning.py b/bots/exp_roadcrew/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_roadcrew/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_roadcrew/main.py b/bots/exp_roadcrew/main.py new file mode 100644 index 0000000..9e9c510 --- /dev/null +++ b/bots/exp_roadcrew/main.py @@ -0,0 +1,1263 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_ROADCREW_ID = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# exp_relay put the launcher road on the SIEGERS and scored 33%: laying it +# delayed the first gun t35 -> t58, and the tempo loss swamped the fact that it +# doubled guns-on-core (5.0 -> 10.4). That conflated two things - whether the +# road helps, and who pays for it. Here ONE dedicated builder lays it and the +# siegers never stop. Road cost comes out of a builder that was farming, not out +# of siege tempo. Measured launch geometry: pickup 1-2 tiles, throw up to 7, +# 1-turn cooldown, so ~6 spacing gives one hop per turn, and the road then +# serves every builder that follows for the rest of the game. +RELAY_SPACING = 6 +RELAY_MIN_GAIN = 3 +RELAY_MAX = 4 +ROAD_RESERVE = 200 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + elif self.numSpawned == 3: + ct.write_store(SLOT_ROADCREW_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if self._is_roadcrew(ct) and self._run_roadcrew(ct, myLoc): + return + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _is_roadcrew(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_ROADCREW_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _run_roadcrew(self, ct: Controller, myLoc: Position) -> bool: + """Walk the route to their core, dropping launchers ~6 apart.""" + target = self._enemy_core_target() + if target is None: + return False + if ct.get_global_resources() <= ROAD_RESERVE: + return False + dist = abs(myLoc.x - target.x) + abs(myLoc.y - target.y) + if dist <= RELAY_SPACING: + return False # close enough; stop laying road + built = 0 + for uid in ct.get_nearby_buildings(): + if (ct.get_team(uid) == ct.get_team() + and ct.get_entity_type(uid) == EntityType.LAUNCHER): + built += 1 + lp = ct.get_position(uid) + if abs(lp.x - myLoc.x) + abs(lp.y - myLoc.y) < RELAY_SPACING: + self.mapPf.moveTo(ct, target) + return True # keep walking, spacing not reached + if built >= RELAY_MAX: + return False # road finished; rejoin normal life + if ct.can_act(): + for d in CARDINALS: + spot = myLoc.add(d) + if ct.can_build_launcher(spot): + ct.build_launcher(spot) + return True + self.mapPf.moveTo(ct, target) + return True + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder walking past, toward their core.""" + target = self._enemy_core_target() + if target is None: + return + me = ct.get_position() + my_team = ct.get_team() + tiles = ct.get_attackable_tiles_from( + me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != my_team + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best, best_d = None, here - RELAY_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < best_d and ct.can_launch(bot, t): + best, best_d = t, d + if best is not None: + ct.launch(bot, best) + return + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_roadcrew/mapPathfinding.py b/bots/exp_roadcrew/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_roadcrew/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_roadcrew/symmetry.py b/bots/exp_roadcrew/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_roadcrew/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out From 88acaeb8a0fe5ae1aeaf5831e04efe7268d4b40a Mon Sep 17 00:00:00 2001 From: Subodh Date: Sat, 1 Aug 2026 21:52:51 +0200 Subject: [PATCH 06/30] trace Pantheon builder-by-builder: they specialise like us, and scale is not it builder_trace.py (new): the replay records a create event with the new entity's position but not who built it, so this tracks every builder's position per turn and attributes each building to the adjacent friendly builder. That yields a per-builder timeline. Pantheon, one game: builder 3 t3 harvester, t5-t33 nine conveyors pure economy builder 5 t9 harvester, t11-t25 eight conveyors pure economy builder 88 t34,37,39,41 four gunners pure delivery builder 97 t44,50 two gunners pure delivery They do NOT interleave farming and delivery - they specialise exactly as we do, which kills the "their builders do both" hypothesis from the previous commit. exp_phase: first CADRE_SIZE builders farm, everyone later sieges (the ordering the trace implies, with the parent's spawn rule untouched, unlike exp_cadre). Better economy - 5.2 harvesters, 41.9 conveyors vs 3.8 and 32.8 - and the same total guns, but they land late and in the wrong place: first gun t51 vs t34, 1.5 on the enemy core vs 3.5. exp_phase2 flattens the spawn curve on top; more guns (10.8 vs 7.8 total) and still 2.0 on core at t56. CORRECTION: I read ids 88/97 as evidence Pantheon fields ~90 units. Ids are global across both teams and all entity types, and they create ~80 entities a game (40 barriers, 21 guns, 18 conveyors), so those ids only mean "created late". Unit counts are comparable - our median is 12 at game end. The scale hypothesis is dead, and so is the concurrency one: their gun builders travel DURING the economy phase, which is what exp_siege_on_sight already does. Fifteen experiments, no improvement on live v2. The remaining difference is per-unit efficiency inside the firing band, which none of these variants has isolated. Co-Authored-By: Claude Opus 5 --- bots/exp_phase/README.md | 19 + bots/exp_phase/initialSpawning.py | 120 +++ bots/exp_phase/main.py | 1197 +++++++++++++++++++++++++++ bots/exp_phase/mapPathfinding.py | 392 +++++++++ bots/exp_phase/symmetry.py | 116 +++ bots/exp_phase2/README.md | 19 + bots/exp_phase2/initialSpawning.py | 120 +++ bots/exp_phase2/main.py | 1205 ++++++++++++++++++++++++++++ bots/exp_phase2/mapPathfinding.py | 392 +++++++++ bots/exp_phase2/symmetry.py | 116 +++ builder_trace.py | 63 ++ 11 files changed, 3759 insertions(+) create mode 100644 bots/exp_phase/README.md create mode 100644 bots/exp_phase/initialSpawning.py create mode 100644 bots/exp_phase/main.py create mode 100644 bots/exp_phase/mapPathfinding.py create mode 100644 bots/exp_phase/symmetry.py create mode 100644 bots/exp_phase2/README.md create mode 100644 bots/exp_phase2/initialSpawning.py create mode 100644 bots/exp_phase2/main.py create mode 100644 bots/exp_phase2/mapPathfinding.py create mode 100644 bots/exp_phase2/symmetry.py create mode 100644 builder_trace.py diff --git a/bots/exp_phase/README.md b/bots/exp_phase/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_phase/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_phase/initialSpawning.py b/bots/exp_phase/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_phase/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_phase/main.py b/bots/exp_phase/main.py new file mode 100644 index 0000000..91d338d --- /dev/null +++ b/bots/exp_phase/main.py @@ -0,0 +1,1197 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_CUTOFF = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# Traced turn by turn from a Pantheon replay, attributing each building to the +# builder standing next to it: +# builder 3: t3 harvester, t5-t33 nine conveyors (pure economy) +# builder 5: t9 harvester, t11-t25 eight conveyors (pure economy) +# builder 88: t34,37,39,41 four gunners (pure delivery) +# builder 97: t44,50 two gunners (pure delivery) +# They specialise exactly as we do. The difference is ORDER: their economy +# builders are the EARLY ids and their gun builders are LATE ones, spawned once +# income exists. We designate siegers as spawns #1 and #2 and send them off at +# ~t5, committing our first builders to delivery before there is an economy to +# sustain it. +# So: the first CADRE_SIZE builders farm, everything spawned later delivers. +# exp_cadre tested this shape but also tripled the spawn rate and burned the +# bank on 30-Ti builders; the spawn rule here is the parent's, untouched. +CADRE_SIZE = 4 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + if self.numSpawned + 1 == CADRE_SIZE: + # ids only increase, so a threshold is a stable role test. + # Everyone after the cadre is a sieger; the cadre never is. + ct.write_store(SLOT_SIEGER_CUTOFF, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + cutoff = ct.read_store(SLOT_SIEGER_CUTOFF) + return cutoff != 0 and ct.get_id() + 1 > cutoff + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_phase/mapPathfinding.py b/bots/exp_phase/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_phase/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_phase/symmetry.py b/bots/exp_phase/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_phase/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_phase2/README.md b/bots/exp_phase2/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_phase2/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_phase2/initialSpawning.py b/bots/exp_phase2/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_phase2/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_phase2/main.py b/bots/exp_phase2/main.py new file mode 100644 index 0000000..4c5e827 --- /dev/null +++ b/bots/exp_phase2/main.py @@ -0,0 +1,1205 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_CUTOFF = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# Traced turn by turn from a Pantheon replay, attributing each building to the +# builder standing next to it: +# builder 3: t3 harvester, t5-t33 nine conveyors (pure economy) +# builder 5: t9 harvester, t11-t25 eight conveyors (pure economy) +# builder 88: t34,37,39,41 four gunners (pure delivery) +# builder 97: t44,50 two gunners (pure delivery) +# They specialise exactly as we do. The difference is ORDER: their economy +# builders are the EARLY ids and their gun builders are LATE ones, spawned once +# income exists. We designate siegers as spawns #1 and #2 and send them off at +# ~t5, committing our first builders to delivery before there is an economy to +# sustain it. +# So: the first CADRE_SIZE builders farm, everything spawned later delivers. +# exp_cadre tested this shape but also tripled the spawn rate and burned the +# bank on 30-Ti builders; the spawn rule here is the parent's, untouched. +CADRE_SIZE = 4 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + if self.numSpawned + 1 == CADRE_SIZE: + # ids only increase, so a threshold is a stable role test. + # Everyone after the cadre is a sieger; the cadre never is. + ct.write_store(SLOT_SIEGER_CUTOFF, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + # Pantheon's gun builders are ids 88 and 97 - by t34 they have MANY + # units, and every one spawned after the economy phase goes straight to + # delivery. exp_phase got the ORDER right and still landed 1.5 guns on + # the core because our spawn rule caps the population: at 5 builders the + # threshold is already 380 Ti, so "everyone after the cadre" is nearly + # nobody. exp_cadre's mistake was cutting the base to 40 AND shrinking + # the cadre; this keeps the cadre at 4 and only flattens the growth + # term, so builders stay affordable once income exists. + if globalTitanium > 80 + 25 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + cutoff = ct.read_store(SLOT_SIEGER_CUTOFF) + return cutoff != 0 and ct.get_id() + 1 > cutoff + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_phase2/mapPathfinding.py b/bots/exp_phase2/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_phase2/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_phase2/symmetry.py b/bots/exp_phase2/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_phase2/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/builder_trace.py b/builder_trace.py new file mode 100644 index 0000000..83ae30f --- /dev/null +++ b/builder_trace.py @@ -0,0 +1,63 @@ +"""Attribute each building to the builder that was standing next to it. + +The replay records a create event with the new entity's position but not who +made it, so we track every builder's position per turn and attribute a build to +an adjacent friendly builder. That yields a per-builder timeline: does one unit +farm AND deliver, or do they specialise? +""" +import sys, collections +sys.path.insert(0, "/mnt/c/Users/subodh/Downloads/fcode") +from replay_stats import walk, pos_of, PAYLOAD_TYPE + +def trace(path, team_wanted): + top = walk(open(path, "rb").read()) + turns = top[(3, 'm')] + pos = {} # entity id -> current position + team = {} # entity id -> team + kind = {} + timeline = collections.defaultdict(list) + for r, tm in enumerate(turns): + t = walk(tm) or {} + builds = [] + for e in t.get((1, 'm'), []): + se = walk(e) or {} + if (1, 'm') in se: # create + outer = walk(se[(1, 'm')][0]) or {} + c = walk(outer[(1, 'm')][0]) if (1, 'm') in outer else outer + c = c or {} + cid = c.get((1, 'v'), [None])[0] + tm_ = c.get((2, 'v'), [0])[0] + p = pos_of(c[(3, 'm')][0]) if (3, 'm') in c else (0, 0) + k = None + for key in c: + if key[0] in PAYLOAD_TYPE: + k = PAYLOAD_TYPE[key[0]] + if cid is not None: + pos[cid], team[cid], kind[cid] = p, tm_, k + if k and k != "builder" and tm_ == team_wanted: + builds.append((p, k)) + elif (2, 'm') in se: # move + m = walk(se[(2, 'm')][0]) or {} + mid = m.get((1, 'v'), [None])[0] + if mid is not None and (2, 'm') in m: + pos[mid] = pos_of(m[(2, 'm')][0]) + for p, k in builds: + best = None + for eid, ep in pos.items(): + if team.get(eid) != team_wanted or kind.get(eid) != "builder": + continue + if abs(ep[0] - p[0]) + abs(ep[1] - p[1]) <= 1: + best = eid + break + if best is not None: + timeline[best].append((r, k)) + return timeline + +tl = trace(sys.argv[1], int(sys.argv[2])) +rows = sorted(tl.items(), key=lambda kv: -len(kv[1]))[:6] +print(f"{'builder':>8s} {'builds':>6s} timeline (turn:what)") +for eid, evs in rows: + kinds = collections.Counter(k for _, k in evs) + s = " ".join(f"{r}:{k[:4]}" for r, k in evs[:16]) + print(f"{eid:8d} {len(evs):6d} {s}") + print(f"{'':8s} {'':6s} mix: {dict(kinds)}") From d6bae0841eecce1fc006a5620ba0b0f39d012df4 Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 01:33:12 +0200 Subject: [PATCH 07/30] ammo is not the constraint either - and v2 is validated on the ladder exp_ammo 36% (CI 29-43, kills 45-101). I read the parent's `if globalAmmo < 20: convert(20 - globalAmmo)` as a hard ceiling on ammunition. It is not - runCore executes every turn, so it is a per-turn TOP-UP to 20, i.e. ~10 shots and 100 damage per turn sustained, enough to kill a core in five turns. Ammo was never starved. The variant then made things worse by reserving only 150 Ti against a 120 gun floor, starving the siege it was meant to feed; retuning to 260 still left core damage at 256 against the parent's 535. Pantheon's win condition, resolved from 120 rounds: 86% core_destroyed at a median of t50, 14% titanium_collected at t1000, and 59% of all wins under 60 turns. They kill fast. An earlier note that their average core damage (357) was below a core's 500 HP was WRONG - that average mixed their losses and their 1000-turn games together. LIVE RESULT for exp_siege_on_sight (v2), 215 rounds: overall 106-109 (49%) against v1's 156-129 (55%), BUT 70 of v2's rounds were against Pantheon and SmartFridge versus 35 of v1's. Like-for-like on the same schedule v2 is 49% against v1's expected 46% - about +3 points, 1 SD. vs OopsGotYourElo 70% (v1: 52%), vs Besvikomat 57% (v1: 46%), vs SmartFridge 23% (v1: 10%). Rating 1652, rank #3 of 89, up from #5. Sixteen local experiments, all rejected; the one change that shipped is holding up on the ladder. Co-Authored-By: Claude Opus 5 --- bots/exp_ammo/README.md | 19 + bots/exp_ammo/initialSpawning.py | 120 +++ bots/exp_ammo/main.py | 1200 ++++++++++++++++++++++++++++++ bots/exp_ammo/mapPathfinding.py | 392 ++++++++++ bots/exp_ammo/symmetry.py | 116 +++ 5 files changed, 1847 insertions(+) create mode 100644 bots/exp_ammo/README.md create mode 100644 bots/exp_ammo/initialSpawning.py create mode 100644 bots/exp_ammo/main.py create mode 100644 bots/exp_ammo/mapPathfinding.py create mode 100644 bots/exp_ammo/symmetry.py diff --git a/bots/exp_ammo/README.md b/bots/exp_ammo/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_ammo/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_ammo/initialSpawning.py b/bots/exp_ammo/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_ammo/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_ammo/main.py b/bots/exp_ammo/main.py new file mode 100644 index 0000000..72b5500 --- /dev/null +++ b/bots/exp_ammo/main.py @@ -0,0 +1,1200 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# Killing a core is 50 gunner shots at 2 ammo each = 100 AMMO. The parent caps +# the pool at 20 - ten shots, 100 damage, one fifth of a core - and only refills +# when it drops below 20, once per turn. Several guns on a 1-turn cooldown drain +# that in a single turn, so the siege spends most of its life dry while the core +# sits on ~470 unspent titanium. Conversion is 1:1, same-turn, once per team per +# turn, so holding titanium instead of ammo buys nothing. +# Pantheon kills by t50 in 86% of their wins; that is ~100 ammo spent inside 50 +# turns, which the parent's policy cannot physically supply. +AMMO_TARGET = 120 # a core's worth of shots plus margin +# First attempt reserved only 150 Ti and made things WORSE - 4.8 guns and 248 +# core damage against the parent's 6.2 and 585 - because SIEGE_TITANIUM_FLOOR +# is 120, so converting down to 150 left a 30 Ti margin and the siege could +# barely build. Guns must always outrank ammo. +AMMO_TITANIUM_RESERVE = 260 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < AMMO_TARGET and globalTitanium > AMMO_TITANIUM_RESERVE: + want = min(AMMO_TARGET - globalAmmo, + globalTitanium - AMMO_TITANIUM_RESERVE) + if want > 0 and ct.can_convert_ammo(want): + ct.convert_ammo(want) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_ammo/mapPathfinding.py b/bots/exp_ammo/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_ammo/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_ammo/symmetry.py b/bots/exp_ammo/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_ammo/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out From b522200dec8ba8bb0ab841fd93a917516ffafa8a Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 10:28:33 +0200 Subject: [PATCH 08/30] port v6's recall: the team's only working answer to Pantheon Teammate pushed v3-v6 while I was iterating. Per-version LIVE records show one thing that matters enormously: v2 (ours) 54% (180 rounds) vs Pantheon 1-14 (7%) v3 56% (205 rounds) vs Pantheon 1-19 (5%) v6 56% ( 80 rounds) vs Pantheon 10-5 (67%) Orizon 1-9 v6 is the only bot any of us has fielded that beats Pantheon - the matchup I spent sixteen experiments failing to crack. Its mechanism is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely hurt AND a real shooter is in vision (hp < 400 + nearestEnemyGunner), and units come home. Crucially it is a SCORED state worth 7-9, below attack's core-defence ceiling of 12, so a unit that can build a killing gunner still builds it. exp_recall - ported as a hard override. REGRESSION: 44% vs our v2 (CI 39-49, kills 120-169). It pulled siegers off live firing seats. exp_recall2 - ported as the soft priority v6 actually uses: build the gun if there is one, answer the recall only when travelling or when no seat is available. 51% vs our v2 (CI 46-57, kills 147-143) - no regression, which is the right profile for a defensive mechanism. METHODOLOGY WARNING: oogbest-v6 scores 45% against our v2 locally and 52% against spar_rush - the same 52% our v2 scores - yet live it goes 67% against Pantheon where ours goes 7%. spar_rush DOES NOT MODEL PANTHEON, and no local gate we have can see this matchup. The WORKFLOW.md section I added this morning making it a mandatory gate overstates what it proves. Also unaddressed: every version loses to Orizon (#3), Prompt Engineers Anonymous (#4) and The Flotte Experience (#5) at ~10-24%. Three top-five teams, one shared weakness, and nobody has looked at their replays. Co-Authored-By: Claude Opus 5 --- bots/exp_recall/README.md | 19 + bots/exp_recall/initialSpawning.py | 120 +++ bots/exp_recall/main.py | 1211 ++++++++++++++++++++++++++ bots/exp_recall/mapPathfinding.py | 392 +++++++++ bots/exp_recall/symmetry.py | 116 +++ bots/exp_recall2/README.md | 19 + bots/exp_recall2/initialSpawning.py | 120 +++ bots/exp_recall2/main.py | 1228 +++++++++++++++++++++++++++ bots/exp_recall2/mapPathfinding.py | 392 +++++++++ bots/exp_recall2/symmetry.py | 116 +++ bots/oogbest-v3/initialSpawning.py | 131 +++ bots/oogbest-v3/main.py | 588 +++++++++++++ bots/oogbest-v3/mapPathfinding.py | 668 +++++++++++++++ bots/oogbest-v6/initialSpawning.py | 131 +++ bots/oogbest-v6/main.py | 592 +++++++++++++ bots/oogbest-v6/mapPathfinding.py | 668 +++++++++++++++ 16 files changed, 6511 insertions(+) create mode 100644 bots/exp_recall/README.md create mode 100644 bots/exp_recall/initialSpawning.py create mode 100644 bots/exp_recall/main.py create mode 100644 bots/exp_recall/mapPathfinding.py create mode 100644 bots/exp_recall/symmetry.py create mode 100644 bots/exp_recall2/README.md create mode 100644 bots/exp_recall2/initialSpawning.py create mode 100644 bots/exp_recall2/main.py create mode 100644 bots/exp_recall2/mapPathfinding.py create mode 100644 bots/exp_recall2/symmetry.py create mode 100644 bots/oogbest-v3/initialSpawning.py create mode 100644 bots/oogbest-v3/main.py create mode 100644 bots/oogbest-v3/mapPathfinding.py create mode 100644 bots/oogbest-v6/initialSpawning.py create mode 100644 bots/oogbest-v6/main.py create mode 100644 bots/oogbest-v6/mapPathfinding.py diff --git a/bots/exp_recall/README.md b/bots/exp_recall/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_recall/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_recall/initialSpawning.py b/bots/exp_recall/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_recall/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_recall/main.py b/bots/exp_recall/main.py new file mode 100644 index 0000000..ffdf35e --- /dev/null +++ b/bots/exp_recall/main.py @@ -0,0 +1,1211 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + # A sieger holding a seat at their core is worth nothing if ours dies + # first. While the recall is up, siegers defend instead. + if ct.read_store(SLOT_RECALL) == 1 and self._is_sieger(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_recall/mapPathfinding.py b/bots/exp_recall/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_recall/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_recall/symmetry.py b/bots/exp_recall/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_recall/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_recall2/README.md b/bots/exp_recall2/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_recall2/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_recall2/initialSpawning.py b/bots/exp_recall2/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_recall2/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_recall2/main.py b/bots/exp_recall2/main.py new file mode 100644 index 0000000..696b21f --- /dev/null +++ b/bots/exp_recall2/main.py @@ -0,0 +1,1228 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_recall2/mapPathfinding.py b/bots/exp_recall2/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_recall2/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_recall2/symmetry.py b/bots/exp_recall2/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_recall2/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/oogbest-v3/initialSpawning.py b/bots/oogbest-v3/initialSpawning.py new file mode 100644 index 0000000..bbc4388 --- /dev/null +++ b/bots/oogbest-v3/initialSpawning.py @@ -0,0 +1,131 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def targetFor(self, angle, coreCorners): + index = anglePerDir.index(angle) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y) + return Position(start.x + directionMoves[index].x, + start.y + directionMoves[index].y) + + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + center = Position(mapW // 2, mapH // 2) + angles = self.pickBestRays(ct, rayScores, coreCorners) + angles.sort(key=lambda a: self.targetFor(a, coreCorners).distance_squared(center)) + return angles \ No newline at end of file diff --git a/bots/oogbest-v3/main.py b/bots/oogbest-v3/main.py new file mode 100644 index 0000000..c58fa7f --- /dev/null +++ b/bots/oogbest-v3/main.py @@ -0,0 +1,588 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target, slot 8 symmetry (mapPathfinding) +RECALL_SLOT = 9 # core-written: 1 while under attack and low HP, else 0 +TEAM_CORE_SLOT = 10 # core-written every turn: its own position. Gunners never call + # setupMap(), so self.mapPf.teamCore is always None for them - + # this is the only way a gunner can find out where home is. +CORE_HP_FLOOR = 400 # below this + an actual threat gunner in vision, sound the recall +GUNNER_ROTATE_FLOOR_NORMAL = 60 +GUNNER_ROTATE_FLOOR_COREDEFENSE = 25 # relaxed when the best target is a gunner hitting our core + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.mapW = None + self.mapH = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myLoc = ct.get_position() + ct.write_store(TEAM_CORE_SLOT, myLoc.x * 32 + myLoc.y) + + underAttack = ct.get_hp() < CORE_HP_FLOOR and self.nearestEnemyGunner(ct, ct.get_team()) is not None + ct.write_store(RECALL_SLOT, 1 if underAttack else 0) + + if globalTitanium > 200 + 40 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + + if underAttack: + # push ammo up toward 16 instead of the normal floor of 28, but never + # convert the last 28 Ti - still need to be able to afford repairs/builds + convertAmount = min(20 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + elif globalAmmo < 16 and globalTitanium > 20: + if ct.can_convert_ammo(16 - globalAmmo): + ct.convert_ammo(16 - globalAmmo) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + self.runBestState(ct, myLoc) + def coreThreatSpots(self, ct: Controller): + """Every [pos, facing] spot that could hit one of our core's 4 tiles, + read from TEAM_CORE_SLOT since a gunner's own mapPf.teamCore is + always None (gunners never call setupMap()).""" + compact = ct.read_store(TEAM_CORE_SLOT) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) # scan always runs now, resource check moves to the end + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + bestIsCoreDefense = False + for d in DIRECTIONS: + curScore = 0 + curIsCoreDefense = False + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + curIsCoreDefense = True # this gunner is itself aimed at our core + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 4 + if curScore > bestScore: + bestScore = curScore + bestDir = d + bestIsCoreDefense = curIsCoreDefense + if bestDir != myDir: + floor = GUNNER_ROTATE_FLOOR_COREDEFENSE if bestIsCoreDefense else GUNNER_ROTATE_FLOOR_NORMAL + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def nearbyAllyBuilders(self, ct: Controller, nearbyUnits, myTeam): + """[(id, pos), ...] for every ally builder bot in this bot's vision, + including itself. Computed once per turn and reused across every + target-exclusivity check below, instead of re-filtering nearbyUnits + per candidate.""" + allies = [] + for b in nearbyUnits: + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.BUILDER_BOT: + allies.append((b, ct.get_position(b))) + return allies + + def isClosestAllyTo(self, allies, myId: int, myLoc: Position, target: Position) -> bool: + """True if no other visible ally builder is closer to target than we + are (ties broken by lower id). Needs no messaging: every bot computes + the same answer from its own vision, so as long as the two builders + contending for a target can see each other, they agree on who backs + off - one keeps the candidate, the other falls through to its next + best option instead of walking to a target someone else already has. + A builder outside our vision that is secretly closer isn't accounted + for; that's a double-claim, not a wasted trip, and gets no worse than + today's behaviour (which does no exclusivity check at all).""" + for aId, aPos in allies: + if aId == myId: + continue + aDist = aPos.distance_squared(target) + myDist = myLoc.distance_squared(target) + if aDist < myDist or (aDist == myDist and aId < myId): + return False + return True + + def coveredTiles(self, ct: Controller, myTeam): + """Every tile some existing team gunner or sentinel already threatens. + + Gunners are checked at every facing, not just their current one - the + decision here is whether to BUILD another turret, so what matters is + whether an existing one could be rotated onto this target, not where + it happens to be pointed this turn. Sentinels cannot rotate, so only + their built facing is checked. This is the raw attack pattern (walls + do not block it), so a wall-shielded target can be under-covered - + that just means we occasionally build a redundant turret, not that + we skip a needed one. + """ + covered = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + covered.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + covered.add((t.x, t.y)) + return covered + + def nearestEnemyGunner(self, ct: Controller, myTeam): + """Nearest enemy GUNNER that can actually hit one of our core's 4 + tiles right now, or None. A gunner merely being nearby isn't enough - + its exact (position, facing) has to be one of the spots gunnerSpots + finds for at least one core tile, i.e. it has to actually be aimed at + us, not just standing around close by.""" + if ct.get_entity_type(ct.get_id()) == EntityType.CORE: + myLoc = ct.get_position() # core's own tile is the corner tiles' top-left + else: + myLoc = self.mapPf.teamCore + coreCorners = [myLoc, myLoc.add(Direction.EAST), myLoc.add(Direction.SOUTH), + myLoc.add(Direction.SOUTH).add(Direction.EAST)] + threatSpots = set() + for corner in coreCorners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + threatSpots.add((spotPos.x, spotPos.y, spotDir)) + best, bestDist = None, 4096 + for b in ct.get_nearby_entities(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) == EntityType.GUNNER: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + if (bPos.x, bPos.y, bDir) not in threatSpots: + continue # nearby, but not actually aimed to hit the core + dist = myLoc.distance_squared(bPos) + if dist < bestDist: + bestDist = dist + best = bPos + return best + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + myId = ct.get_id() + allyBuilders = self.nearbyAllyBuilders(ct, nearbyUnits, myTeam) + + attackScore, attackPos = self.scoreAttack(ct, myLoc, myTeam, myId, allyBuilders, nearbyUnits) + retreatScore, retreatPos = self.scoreRetreat(ct, myLoc) + healScore, healPos = self.scoreHeal(ct, myLoc, myTeam, nearbyUnits) + routeScore, routePos, routeDir = self.scoreRoute(ct, myLoc, myTeam, myId, allyBuilders, nearbyUnits) + harvestScore, harvestPos = self.scoreHarvest(ct, myLoc, myTeam, myId, allyBuilders) + exploreScore, explorePos = self.scoreExplore(ct, myLoc) + + stateScores = [attackScore, retreatScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == retreatScore: + self.retreat(ct, retreatPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + + def scoreRetreat(self, ct: Controller, myLoc: Position): + """Core-sounded recall: below attack's core-defense ceiling (12) so a + bot that can actually build a killing gunner still does that instead, + but above everything else - urgent, though scaled a bit by distance + per the spec (closer bots weigh it slightly more, though it stays + high even from across the map so distant bots still come home).""" + retreatScore = 0 + retreatPos = None + teamCore = self.mapPf.teamCore + if teamCore is not None and ct.read_store(RECALL_SLOT) == 1: + dist = myLoc.distance_squared(teamCore) + retreatScore = max(7, 9 * (1 - dist / 300)) + retreatPos = teamCore + return retreatScore, retreatPos + + def retreat(self, ct: Controller, retreatPos: Position): + self.mapPf.moveTo(ct, retreatPos) + + def scoreAttack(self, ct: Controller, myLoc: Position, myTeam, myId, allyBuilders, nearbyUnits): + # attack, max score of 10 + attackScore = 0 + attackPos = None + if ct.get_global_resources() > 10: + coreThreat = self.nearestEnemyGunner(ct, ct.get_team()) + if coreThreat is not None and (coreThreat.x, coreThreat.y) not in self.coveredTiles(ct, myTeam): + if self.isClosestAllyTo(allyBuilders, myId, myLoc, coreThreat): # only the closest bot commits - others fall through instead of piling on + attackScore = 15 + attackPos = coreThreat + if ct.get_id() > 4: # dont attack if u r broke and leave one bot for defense + covered = self.coveredTiles(ct, myTeam) + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE] and ct.get_global_resources() > 25: + buildingScore = 10 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL] and ct.get_global_resources() > 110: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER] and ct.get_global_resources() > 115: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT and ct.get_global_resources() > 125: + buildingScore = 4 + elif ct.get_global_resources() > 160: + buildingScore = 1 + else: + continue + if (bPos.x, bPos.y) in covered and buildingScore != 10: + continue # an existing team gunner/sentinel can already reach this target + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/48) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + return attackScore, attackPos + + def scoreHeal(self, ct: Controller, myLoc: Position, myTeam, nearbyUnits): + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.BUILDER_BOT] and ct.get_global_resources() > 28: + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER] and ct.get_global_resources() > 24: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + if dist > 0: + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + return healScore, healPos + + def scoreRoute(self, ct: Controller, myLoc: Position, myTeam, myId, allyBuilders, nearbyUnits): + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType in (EntityType.HARVESTER, EntityType.CONVEYOR) and not self.isClosestAllyTo(allyBuilders, myId, myLoc, bPos): + continue # a closer ally already has this harvester/conveyor - dont chase it too + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + return routeScore, routePos, routeDir + + def scoreHarvest(self, ct: Controller, myLoc: Position, myTeam, myId, allyBuilders): + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + teamCore = self.mapPf.teamCore + if teamCore is not None: + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + if not self.isClosestAllyTo(allyBuilders, myId, myLoc, tile): + continue # a closer ally already has this ore tile + if (tile.x, tile.y) in enemyThreatened: + continue # dont place a harvester somewhere an enemy turret can hit it + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore and ct.get_global_resources() > dist / 7: + harvestPos = tile + harvestScore = tileScore + return harvestScore, harvestPos + + def scoreExplore(self, ct: Controller, myLoc: Position): + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + if self.mapPf.enemyCorePos is not None and self.mapPf.myNum < 3: + explorePos = self.mapPf.enemyCorePos + exploreScore = 5 + elif self.mapPf.myNum < 3: + explorePos = Position(self.mapW // 2, self.mapH // 2) + exploreScore = 7 + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + return exploreScore, explorePos + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + """True if a TEAM-owned building sits strictly between gunnerSpot and + attackPos along facing d. Builder bots (either team) and enemy + buildings are deliberately ignored: builder bots are transient, and an + enemy building in the way just becomes the gunner's real target + instead of attackPos, which is fine - only a permanent team building + forever wastes the shot. dist < 10 and != 5 (checked by the caller) + guarantees this is a true straight cardinal/diagonal line, so walking + step by step in d reaches attackPos exactly.""" + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): # bound the walk, just in case + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False # left the map before reaching the target - shouldn't happen + if self.mapPf.getTileEnv(Position(x, y)) == 2: # wall + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if self.rayBlockedByTeam(ct, gunnerSpot, attackPos, gunnerDir, myTeam): + continue # a team building sits between here and the target - try another spot + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # only stop once we've actually built a gunner with a clear line + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType == EntityType.BARRIER: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + + myLoc = ct.get_position() + if ct.can_heal(healPos): + ct.heal(healPos) + else: + + if myLoc.distance_squared(healPos) > 1: + self.mapPf.moveTo(ct, healPos) + elif myLoc.distance_squared(healPos) < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + if harvestPos.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, harvestPos) + elif harvestPos.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + if explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) \ No newline at end of file diff --git a/bots/oogbest-v3/mapPathfinding.py b/bots/oogbest-v3/mapPathfinding.py new file mode 100644 index 0000000..8ea1880 --- /dev/null +++ b/bots/oogbest-v3/mapPathfinding.py @@ -0,0 +1,668 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 initial target) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allWalls = [] + self.allOres = [] + self.wallKeys = {} + self.oreKeys = {} + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + def addWall(self, tile: Position): # keyed so re-seeing a tile every turn cant duplicate it + key = tile.x * 32 + tile.y + if key not in self.wallKeys: + self.wallKeys[key] = True + self.allWalls.append(tile) + + def addOre(self, tile: Position): + key = tile.x * 32 + tile.y + if key not in self.oreKeys: + self.oreKeys[key] = True + self.allOres.append(tile) + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if tileEnv == Environment.WALL: + self.addWall(tile) + elif tileEnv == Environment.ORE_TITANIUM: + self.addOre(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + if t_type == 2: + self.addWall(Position(x, y)) + elif t_type == 1: + self.addOre(Position(x, y)) # ore another bot found counts too + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + """Every tile any visible enemy GUNNER (any facing, since it could + rotate) or SENTINEL (fixed facing) could hit. Cached per round - + shared by conveyor routing and harvester placement, both of which + want to avoid these, so it's one entity scan per turn, not one per + caller.""" + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + """Same as conveyorTileCost, except a tile within enemy turret range + gets a flat cost of 48 instead of its normal value - strongly + discouraged but not a hard block, so a route can still cross one if + there's truly no other way through.""" + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [28, 'done'] # loaded + if endDist > otherDist: + return [48, 'done'] + return [4, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + self.moveTo(ct, teamCore) + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Mirrors the sentinel version: walks each line outward from target, so + the facing to return is the reverse of the walk. With blocked=True a + wall or building on the line ends that direction early - it cannot be + stood on and it shields everything behind it. Unseen tiles assumed + clear. Takes mapW/mapH explicitly (rather than self.mapW/self.mapH) + and skips the wall check entirely if self.fullMap was never built, so + this also works from the core, which never calls setupMap().""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) diff --git a/bots/oogbest-v6/initialSpawning.py b/bots/oogbest-v6/initialSpawning.py new file mode 100644 index 0000000..bbc4388 --- /dev/null +++ b/bots/oogbest-v6/initialSpawning.py @@ -0,0 +1,131 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def targetFor(self, angle, coreCorners): + index = anglePerDir.index(angle) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y) + return Position(start.x + directionMoves[index].x, + start.y + directionMoves[index].y) + + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + center = Position(mapW // 2, mapH // 2) + angles = self.pickBestRays(ct, rayScores, coreCorners) + angles.sort(key=lambda a: self.targetFor(a, coreCorners).distance_squared(center)) + return angles \ No newline at end of file diff --git a/bots/oogbest-v6/main.py b/bots/oogbest-v6/main.py new file mode 100644 index 0000000..b203a8b --- /dev/null +++ b/bots/oogbest-v6/main.py @@ -0,0 +1,592 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target, slot 8 symmetry (mapPathfinding) +RECALL_SLOT = 9 # core-written: 1 while under attack and low HP, else 0 +TEAM_CORE_SLOT = 10 # core-written every turn: its own position. Gunners never call + # setupMap(), so self.mapPf.teamCore is always None for them - + # this is the only way a gunner can find out where home is. +CORE_HP_FLOOR = 400 # below this + an actual threat gunner in vision, sound the recall +GUNNER_ROTATE_FLOOR_NORMAL = 60 +GUNNER_ROTATE_FLOOR_COREDEFENSE = 25 # relaxed when the best target is a gunner hitting our core + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.mapW = None + self.mapH = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + + globalTitanium = ct.get_global_resources() + globalAmmo = ct.get_global_ammo() + + myLoc = ct.get_position() + ct.write_store(TEAM_CORE_SLOT, myLoc.x * 32 + myLoc.y) + + underAttack = ct.get_hp() < CORE_HP_FLOOR and self.nearestEnemyGunner(ct, ct.get_team()) is not None + ct.write_store(RECALL_SLOT, 1 if underAttack else 0) + + if globalTitanium > 200 + 40 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + + if underAttack: + # push ammo up toward 16 instead of the normal floor of 28, but never + # convert the last 28 Ti - still need to be able to afford repairs/builds + convertAmount = min(20 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + elif globalAmmo < 16 and globalTitanium > 20: + if ct.can_convert_ammo(16 - globalAmmo): + ct.convert_ammo(16 - globalAmmo) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + self.runBestState(ct, myLoc) + + def coreThreatSpots(self, ct: Controller): + """Every [pos, facing] spot that could hit one of our core's 4 tiles, + read from TEAM_CORE_SLOT since a gunner's own mapPf.teamCore is + always None (gunners never call setupMap()).""" + compact = ct.read_store(TEAM_CORE_SLOT) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) # scan always runs now, resource check moves to the end + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + bestIsCoreDefense = False + for d in DIRECTIONS: + curScore = 0 + curIsCoreDefense = False + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + curIsCoreDefense = True # this gunner is itself aimed at our core + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 4 + if curScore > bestScore: + bestScore = curScore + bestDir = d + bestIsCoreDefense = curIsCoreDefense + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else GUNNER_ROTATE_FLOOR_NORMAL + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def nearbyAllyBuilders(self, ct: Controller, nearbyUnits, myTeam): + """[(id, pos), ...] for every ally builder bot in this bot's vision, + including itself. Computed once per turn and reused across every + target-exclusivity check below, instead of re-filtering nearbyUnits + per candidate.""" + allies = [] + for b in nearbyUnits: + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.BUILDER_BOT: + allies.append((b, ct.get_position(b))) + return allies + + def isClosestAllyTo(self, allies, myId: int, myLoc: Position, target: Position) -> bool: + """True if no other visible ally builder is closer to target than we + are (ties broken by lower id). Needs no messaging: every bot computes + the same answer from its own vision, so as long as the two builders + contending for a target can see each other, they agree on who backs + off - one keeps the candidate, the other falls through to its next + best option instead of walking to a target someone else already has. + A builder outside our vision that is secretly closer isn't accounted + for; that's a double-claim, not a wasted trip, and gets no worse than + today's behaviour (which does no exclusivity check at all).""" + for aId, aPos in allies: + if aId == myId: + continue + aDist = aPos.distance_squared(target) + myDist = myLoc.distance_squared(target) + if aDist < myDist or (aDist == myDist and aId < myId): + return False + return True + + def coveredTiles(self, ct: Controller, myTeam): + """Every tile some existing team gunner or sentinel already threatens. + + Gunners are checked at every facing, not just their current one - the + decision here is whether to BUILD another turret, so what matters is + whether an existing one could be rotated onto this target, not where + it happens to be pointed this turn. Sentinels cannot rotate, so only + their built facing is checked. This is the raw attack pattern (walls + do not block it), so a wall-shielded target can be under-covered - + that just means we occasionally build a redundant turret, not that + we skip a needed one. + """ + covered = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + covered.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + covered.add((t.x, t.y)) + return covered + + def nearestEnemyGunner(self, ct: Controller, myTeam): + """Nearest enemy GUNNER that can actually hit one of our core's 4 + tiles right now, or None. A gunner merely being nearby isn't enough - + its exact (position, facing) has to be one of the spots gunnerSpots + finds for at least one core tile, i.e. it has to actually be aimed at + us, not just standing around close by.""" + if ct.get_entity_type(ct.get_id()) == EntityType.CORE: + myLoc = ct.get_position() # core's own tile is the corner tiles' top-left + else: + myLoc = self.mapPf.teamCore + coreCorners = [myLoc, myLoc.add(Direction.EAST), myLoc.add(Direction.SOUTH), + myLoc.add(Direction.SOUTH).add(Direction.EAST)] + threatSpots = set() + for corner in coreCorners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + threatSpots.add((spotPos.x, spotPos.y, spotDir)) + best, bestDist = None, 4096 + for b in ct.get_nearby_entities(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) == EntityType.GUNNER: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + if (bPos.x, bPos.y, bDir) not in threatSpots: + continue # nearby, but not actually aimed to hit the core + dist = myLoc.distance_squared(bPos) + if dist < bestDist: + bestDist = dist + best = bPos + return best + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + myId = ct.get_id() + allyBuilders = self.nearbyAllyBuilders(ct, nearbyUnits, myTeam) + + attackScore, attackPos = self.scoreAttack(ct, myLoc, myTeam, myId, allyBuilders, nearbyUnits) + retreatScore, retreatPos = self.scoreRetreat(ct, myLoc) + healScore, healPos = self.scoreHeal(ct, myLoc, myTeam, nearbyUnits) + routeScore, routePos, routeDir = self.scoreRoute(ct, myLoc, myTeam, myId, allyBuilders, nearbyUnits) + harvestScore, harvestPos = self.scoreHarvest(ct, myLoc, myTeam, myId, allyBuilders) + exploreScore, explorePos = self.scoreExplore(ct, myLoc) + + stateScores = [attackScore, retreatScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == retreatScore: + self.retreat(ct, retreatPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + + def scoreRetreat(self, ct: Controller, myLoc: Position): + """Core-sounded recall: below attack's core-defense ceiling (12) so a + bot that can actually build a killing gunner still does that instead, + but above everything else - urgent, though scaled a bit by distance + per the spec (closer bots weigh it slightly more, though it stays + high even from across the map so distant bots still come home).""" + retreatScore = 0 + retreatPos = None + teamCore = self.mapPf.teamCore + if teamCore is not None and ct.read_store(RECALL_SLOT) == 1: + dist = myLoc.distance_squared(teamCore) + retreatScore = max(7, 9 * (1 - dist / 300)) + retreatPos = teamCore + return retreatScore, retreatPos + + def retreat(self, ct: Controller, retreatPos: Position): + self.mapPf.moveTo(ct, retreatPos) + + def scoreAttack(self, ct: Controller, myLoc: Position, myTeam, myId, allyBuilders, nearbyUnits): + # attack, max score of 10 + attackScore = 0 + attackPos = None + if ct.get_global_resources() > 10: + coreThreat = self.nearestEnemyGunner(ct, ct.get_team()) + if coreThreat is not None: + attackScore = 15 + attackPos = coreThreat + if ct.get_id() > 4: # dont attack if u r broke and leave one bot for defense + covered = self.coveredTiles(ct, myTeam) + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE] and ct.get_global_resources() > 25: + buildingScore = 10 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL] and ct.get_global_resources() > 110: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER] and ct.get_global_resources() > 115: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT and ct.get_global_resources() > 125: + buildingScore = 4 + elif ct.get_global_resources() > 160: + buildingScore = 1 + else: + continue + if (bPos.x, bPos.y) in covered and buildingScore != 10: + continue # an existing team gunner/sentinel can already reach this target + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/48) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + return attackScore, attackPos + + def scoreHeal(self, ct: Controller, myLoc: Position, myTeam, nearbyUnits): + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.BUILDER_BOT] and ct.get_global_resources() > 28: + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER] and ct.get_global_resources() > 24: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + if dist > 0: + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + return healScore, healPos + + def scoreRoute(self, ct: Controller, myLoc: Position, myTeam, myId, allyBuilders, nearbyUnits): + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType in (EntityType.HARVESTER, EntityType.CONVEYOR) and not self.isClosestAllyTo(allyBuilders, myId, myLoc, bPos): + continue # a closer ally already has this harvester/conveyor - dont chase it too + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + return routeScore, routePos, routeDir + + def scoreHarvest(self, ct: Controller, myLoc: Position, myTeam, myId, allyBuilders): + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + teamCore = self.mapPf.teamCore + if teamCore is not None: + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + if not self.isClosestAllyTo(allyBuilders, myId, myLoc, tile): + continue # a closer ally already has this ore tile + if (tile.x, tile.y) in enemyThreatened: + continue # dont place a harvester somewhere an enemy turret can hit it + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore and ct.get_global_resources() > dist / 7: + harvestPos = tile + harvestScore = tileScore + return harvestScore, harvestPos + + def scoreExplore(self, ct: Controller, myLoc: Position): + # explore, max score of 1 + exploreScore = 0 + if self.mapPf.myNum < 3: + if self.mapPf.enemyCorePos is not None: + explorePos = self.mapPf.enemyCorePos + exploreScore = 7 + else: + explorePos = Position(self.mapW // 2, self.mapH // 2) + exploreScore = 7 + else: + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + return exploreScore, explorePos + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + """True if a TEAM-owned building sits strictly between gunnerSpot and + attackPos along facing d. Builder bots (either team) and enemy + buildings are deliberately ignored: builder bots are transient, and an + enemy building in the way just becomes the gunner's real target + instead of attackPos, which is fine - only a permanent team building + forever wastes the shot. dist < 10 and != 5 (checked by the caller) + guarantees this is a true straight cardinal/diagonal line, so walking + step by step in d reaches attackPos exactly.""" + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): # bound the walk, just in case + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False # left the map before reaching the target - shouldn't happen + if self.mapPf.getTileEnv(Position(x, y)) == 2: # wall + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if self.rayBlockedByTeam(ct, gunnerSpot, attackPos, gunnerDir, myTeam): + continue # a team building sits between here and the target - try another spot + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # only stop once we've actually built a gunner with a clear line + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType == EntityType.BARRIER: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + + myLoc = ct.get_position() + if ct.can_heal(healPos): + ct.heal(healPos) + else: + + if myLoc.distance_squared(healPos) > 1: + self.mapPf.moveTo(ct, healPos) + elif myLoc.distance_squared(healPos) < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + if harvestPos.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, harvestPos) + elif harvestPos.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + if explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) \ No newline at end of file diff --git a/bots/oogbest-v6/mapPathfinding.py b/bots/oogbest-v6/mapPathfinding.py new file mode 100644 index 0000000..604e5b5 --- /dev/null +++ b/bots/oogbest-v6/mapPathfinding.py @@ -0,0 +1,668 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 initial target) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 30 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allWalls = [] + self.allOres = [] + self.wallKeys = {} + self.oreKeys = {} + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER): + return False + return True + def addWall(self, tile: Position): # keyed so re-seeing a tile every turn cant duplicate it + key = tile.x * 32 + tile.y + if key not in self.wallKeys: + self.wallKeys[key] = True + self.allWalls.append(tile) + + def addOre(self, tile: Position): + key = tile.x * 32 + tile.y + if key not in self.oreKeys: + self.oreKeys[key] = True + self.allOres.append(tile) + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if tileEnv == Environment.WALL: + self.addWall(tile) + elif tileEnv == Environment.ORE_TITANIUM: + self.addOre(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + if t_type == 2: + self.addWall(Position(x, y)) + elif t_type == 1: + self.addOre(Position(x, y)) # ore another bot found counts too + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + """Every tile any visible enemy GUNNER (any facing, since it could + rotate) or SENTINEL (fixed facing) could hit. Cached per round - + shared by conveyor routing and harvester placement, both of which + want to avoid these, so it's one entity scan per turn, not one per + caller.""" + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + """Same as conveyorTileCost, except a tile within enemy turret range + gets a flat cost of 48 instead of its normal value - strongly + discouraged but not a hard block, so a route can still cross one if + there's truly no other way through.""" + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [28, 'done'] # loaded + if endDist > otherDist: + return [48, 'done'] + return [4, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + self.moveTo(ct, teamCore) + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Mirrors the sentinel version: walks each line outward from target, so + the facing to return is the reverse of the walk. With blocked=True a + wall or building on the line ends that direction early - it cannot be + stood on and it shields everything behind it. Unseen tiles assumed + clear. Takes mapW/mapH explicitly (rather than self.mapW/self.mapH) + and skips the wall check entirely if self.fullMap was never built, so + this also works from the core, which never calls setupMap().""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) From 34d026c3c482da463fdd43e17eecaebef728d51d Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 10:37:58 +0200 Subject: [PATCH 09/30] the pattern, confirmed across four top teams - and a retraction Profiled Orizon (#3), Prompt Engineers Anonymous (#4) and The Flotte Experience (#5) in games AGAINST US, so map and matchup are controlled: team 1st gun guns on OUR core conv harv barr vs us Oogway (us) t24 2.2 13.4 3.0 0.0 Orizon t18 16.0 8.0 1.9 0.2 13-2 Prompt Engineers t26 14.0 3.2 1.0 0.0 17-3 Flotte t22 6.5 0.0 0.0 5.7 22-3 Pantheon t22 18.7 17.7 4.1 39.8 10-5 Every team above us puts 3-8x more guns on our core than we put on theirs, and every one runs a leaner economy than we do. Flotte runs NO economy - zero conveyors, zero harvesters, barriers instead - beats us 22-3, and loses to OopsGotYourElo 5-15, a team we beat 70%. That is a counter to us specifically. RETRACTION: I dismissed guns-on-core as "churn" on the grounds that Pantheon's guns live 8 turns to our 30. That compared Pantheon vs top opponents against our bot vs my own failed variants - not like for like. Four teams measured in games against us show the same gap. The metric was right and the sixteen rejected experiments failed on execution, not on thesis. Flotte proves zero economy works - the configuration strike-v1 attempted and botched at 1.9 guns. Co-Authored-By: Claude Opus 5 --- MEASUREMENT-FINDINGS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/MEASUREMENT-FINDINGS.md b/MEASUREMENT-FINDINGS.md index c73a660..a39e1aa 100644 --- a/MEASUREMENT-FINDINGS.md +++ b/MEASUREMENT-FINDINGS.md @@ -203,6 +203,34 @@ Verified engine facts worth keeping regardless: legal targets for enemies, but also 0 for the friendly control in the same run, so it proves nothing. + +## THE PATTERN, confirmed across four top teams (2026-08-02) + +Profiled in games AGAINST US, so it is like-for-like on map and matchup: + +| team | 1st gun | guns on OUR core | conveyors | harvesters | barriers | vs us | +|---|---|---|---|---|---|---| +| **Oogway (us)** | t24 | **2.2** | 13.4 | 3.0 | 0.0 | - | +| Orizon (#3) | t18 | 16.0 | 8.0 | 1.9 | 0.2 | 13-2 | +| Prompt Engineers (#4) | t26 | 14.0 | 3.2 | 1.0 | 0.0 | 17-3 | +| The Flotte Experience (#5) | t22 | 6.5 | **0.0** | **0.0** | 5.7 | 22-3 | +| Pantheon (#1) | t22 | 18.7 | 17.7 | 4.1 | 39.8 | 10-5 (v6) | + +Every team above us lands 3-8x more guns on our core than we land on theirs, and +every one runs a LEANER economy. Flotte builds no economy at all - zero +conveyors, zero harvesters, barriers instead - and beats us 22-3 while LOSING to +OopsGotYourElo 5-15, a team we beat 70%. That is a specific counter to us, not a +strength gap. + +RETRACTION: I earlier dismissed guns-on-core as "churn" because Pantheon's guns +live 8 turns and ours live 30. That compared Pantheon against top opponents with +our bot against my own failed variants - not like for like. Four teams measured +in games against us all show the same gap. The metric is real; the dismissal was +wrong, and the sixteen rejected experiments failed on EXECUTION, not thesis. + +Flotte is the existence proof that zero economy works, which is exactly what +strike-v1 attempted (27%, 1.9 guns) and botched. + ## Open lead for generalist-v3 Action order follows entity id, and id is assigned at spawn — so **spawn timing From c6b02bc52686b616f3142b20a4e54d4ef8d9f7cf Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 11:46:53 +0200 Subject: [PATCH 10/30] THE TRANSITION: first variant all session to beat the live bot exp_transition 56% vs shipped v7 at 336 rounds (CI 50-61, kills 173-124). Found by tracing the teams that beat us, builder by builder, in games AGAINST US - not by reasoning about mechanisms: ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER b4: t30,31,37,43 gunner b6: t33,34,35 gunner PROMPTENG b3: t1 harv, t3 conv, t5 conv -> STOPS AT t5 b5: t28,30,31 gunner b9: t28,36,39 gunner US (v7) b16: t7 conv, t9 conv, t19 GUNNER, t27 harv, then TEN conveyors through t43 b4: t1 harv ... t22, t24, t26 conv They build a small economy, stop dead, and spend everything after on guns. We never stop - 18.4 conveyors a game to Orizon's 7, and one builder puts down a gunner at t19 and goes back to harvesting at t27. That is why every team above us lands 6-18 guns on our core and we land 2-4. The mechanism is a TRANSITION, not an allocation, which is why exp_phase (role cadre) and spar_pantheon (economy damped from t0) both missed it: the economy has to be built FULLY and then abandoned. One condition: after ECON_CUTOFF the harvest and route states score zero. Profile now matches the target: total guns 14.0 (Orizon 14.0), on-core 3.8 -> 6.9, conveyors 39.5 -> 15.9. Sweeping ECON_CUTOFF over 10/14/20/26/32/40 before shipping - the traces put their transitions at t5-t16, so 26 is probably late. Also measured: v7 and v6 are indistinguishable live (44% vs 45% expected like-for-like, n=50), and v6's Pantheon rate has regressed 67% -> 48% as its sample grew. Both share the same holes: Orizon ~10%, Prompt Engineers 20%. Co-Authored-By: Claude Opus 5 --- bots/exp_trans_10/README.md | 19 + bots/exp_trans_10/initialSpawning.py | 120 +++ bots/exp_trans_10/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_10/mapPathfinding.py | 392 ++++++++ bots/exp_trans_10/symmetry.py | 116 +++ bots/exp_trans_14/README.md | 19 + bots/exp_trans_14/initialSpawning.py | 120 +++ bots/exp_trans_14/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_14/mapPathfinding.py | 392 ++++++++ bots/exp_trans_14/symmetry.py | 116 +++ bots/exp_trans_20/README.md | 19 + bots/exp_trans_20/initialSpawning.py | 120 +++ bots/exp_trans_20/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_20/mapPathfinding.py | 392 ++++++++ bots/exp_trans_20/symmetry.py | 116 +++ bots/exp_trans_32/README.md | 19 + bots/exp_trans_32/initialSpawning.py | 120 +++ bots/exp_trans_32/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_32/mapPathfinding.py | 392 ++++++++ bots/exp_trans_32/symmetry.py | 116 +++ bots/exp_trans_40/README.md | 19 + bots/exp_trans_40/initialSpawning.py | 120 +++ bots/exp_trans_40/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_40/mapPathfinding.py | 392 ++++++++ bots/exp_trans_40/symmetry.py | 116 +++ bots/exp_transition/README.md | 19 + bots/exp_transition/initialSpawning.py | 120 +++ bots/exp_transition/main.py | 1244 ++++++++++++++++++++++++ bots/exp_transition/mapPathfinding.py | 392 ++++++++ bots/exp_transition/symmetry.py | 116 +++ recall2.zip | Bin 0 -> 19086 bytes 31 files changed, 11346 insertions(+) create mode 100644 bots/exp_trans_10/README.md create mode 100644 bots/exp_trans_10/initialSpawning.py create mode 100644 bots/exp_trans_10/main.py create mode 100644 bots/exp_trans_10/mapPathfinding.py create mode 100644 bots/exp_trans_10/symmetry.py create mode 100644 bots/exp_trans_14/README.md create mode 100644 bots/exp_trans_14/initialSpawning.py create mode 100644 bots/exp_trans_14/main.py create mode 100644 bots/exp_trans_14/mapPathfinding.py create mode 100644 bots/exp_trans_14/symmetry.py create mode 100644 bots/exp_trans_20/README.md create mode 100644 bots/exp_trans_20/initialSpawning.py create mode 100644 bots/exp_trans_20/main.py create mode 100644 bots/exp_trans_20/mapPathfinding.py create mode 100644 bots/exp_trans_20/symmetry.py create mode 100644 bots/exp_trans_32/README.md create mode 100644 bots/exp_trans_32/initialSpawning.py create mode 100644 bots/exp_trans_32/main.py create mode 100644 bots/exp_trans_32/mapPathfinding.py create mode 100644 bots/exp_trans_32/symmetry.py create mode 100644 bots/exp_trans_40/README.md create mode 100644 bots/exp_trans_40/initialSpawning.py create mode 100644 bots/exp_trans_40/main.py create mode 100644 bots/exp_trans_40/mapPathfinding.py create mode 100644 bots/exp_trans_40/symmetry.py create mode 100644 bots/exp_transition/README.md create mode 100644 bots/exp_transition/initialSpawning.py create mode 100644 bots/exp_transition/main.py create mode 100644 bots/exp_transition/mapPathfinding.py create mode 100644 bots/exp_transition/symmetry.py create mode 100644 recall2.zip diff --git a/bots/exp_trans_10/README.md b/bots/exp_trans_10/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_10/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_10/initialSpawning.py b/bots/exp_trans_10/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_10/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_10/main.py b/bots/exp_trans_10/main.py new file mode 100644 index 0000000..40cca30 --- /dev/null +++ b/bots/exp_trans_10/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 10 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_10/mapPathfinding.py b/bots/exp_trans_10/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_10/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_10/symmetry.py b/bots/exp_trans_10/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_10/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_14/README.md b/bots/exp_trans_14/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_14/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_14/initialSpawning.py b/bots/exp_trans_14/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_14/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_14/main.py b/bots/exp_trans_14/main.py new file mode 100644 index 0000000..5c4b78c --- /dev/null +++ b/bots/exp_trans_14/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 14 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_14/mapPathfinding.py b/bots/exp_trans_14/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_14/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_14/symmetry.py b/bots/exp_trans_14/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_14/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_20/README.md b/bots/exp_trans_20/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_20/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_20/initialSpawning.py b/bots/exp_trans_20/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_20/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_20/main.py b/bots/exp_trans_20/main.py new file mode 100644 index 0000000..62b8f5e --- /dev/null +++ b/bots/exp_trans_20/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 20 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_20/mapPathfinding.py b/bots/exp_trans_20/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_20/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_20/symmetry.py b/bots/exp_trans_20/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_20/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_32/README.md b/bots/exp_trans_32/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_32/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_32/initialSpawning.py b/bots/exp_trans_32/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_32/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_32/main.py b/bots/exp_trans_32/main.py new file mode 100644 index 0000000..f32225f --- /dev/null +++ b/bots/exp_trans_32/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 32 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_32/mapPathfinding.py b/bots/exp_trans_32/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_32/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_32/symmetry.py b/bots/exp_trans_32/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_32/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_40/README.md b/bots/exp_trans_40/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_40/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_40/initialSpawning.py b/bots/exp_trans_40/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_40/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_40/main.py b/bots/exp_trans_40/main.py new file mode 100644 index 0000000..b7c0eb2 --- /dev/null +++ b/bots/exp_trans_40/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_40/mapPathfinding.py b/bots/exp_trans_40/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_40/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_40/symmetry.py b/bots/exp_trans_40/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_40/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_transition/README.md b/bots/exp_transition/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_transition/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_transition/initialSpawning.py b/bots/exp_transition/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_transition/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_transition/main.py b/bots/exp_transition/main.py new file mode 100644 index 0000000..63fe430 --- /dev/null +++ b/bots/exp_transition/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 26 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_transition/mapPathfinding.py b/bots/exp_transition/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_transition/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_transition/symmetry.py b/bots/exp_transition/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_transition/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/recall2.zip b/recall2.zip new file mode 100644 index 0000000000000000000000000000000000000000..b9fec2d5b46c5e2072783cd5a4b2e99a3365ca39 GIT binary patch literal 19086 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 C@{y+i literal 0 HcmV?d00001 From 02339b65422ad202461547e349c854c677f7935d Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 13:23:29 +0200 Subject: [PATCH 11/30] ship v8 econ-transition (58% at 336, CI 53-63, kills 183-116); trans_def rejected Cutoff sweep over nine values: t10 35%, t14 50%, t20 56%, t26 56%, t32 62%, t40 62%, t50 53%, t65 58%, t80 58%. Their timings do NOT transfer - Orizon stops at t16 and Prompt Engineers at t5, but copying that gives 35-50% because our economy is slower. Confirmed the two leaders at 336 to pay back the selection bias of a nine-way sweep: t32 55% (CI 50-60, spans 50), t40 58% (CI 53-63, excludes 50). Shipped t40 as v8 econ-transition. v8 unrated vs the top five: 6-19 (24%) against v7's 7-25 (28%). A TRADE, not a gain - Prompt Engineers 1-4 -> 4-1 and Orizon 0-5 -> 1-4 (the two teams whose plan this copies), but Pantheon 2-3 -> 0-5, Flotte 2-3 -> 0-5, SmartFridge 2-3 -> 1-4 (the three that rush our core). exp_trans_def tested the obvious explanation - that zeroing economy states empties our half when a rush lands - by letting the recall outrank the transition. REJECTED: 46% vs v8 (CI 41-52, kills 138-167), 45% vs spar_rush. So that mechanism is wrong and v8 stays live. Co-Authored-By: Claude Opus 5 --- bots/exp_trans_50/README.md | 19 + bots/exp_trans_50/initialSpawning.py | 120 +++ bots/exp_trans_50/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_50/mapPathfinding.py | 392 ++++++++ bots/exp_trans_50/symmetry.py | 116 +++ bots/exp_trans_65/README.md | 19 + bots/exp_trans_65/initialSpawning.py | 120 +++ bots/exp_trans_65/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_65/mapPathfinding.py | 392 ++++++++ bots/exp_trans_65/symmetry.py | 116 +++ bots/exp_trans_80/README.md | 19 + bots/exp_trans_80/initialSpawning.py | 120 +++ bots/exp_trans_80/main.py | 1244 ++++++++++++++++++++++++ bots/exp_trans_80/mapPathfinding.py | 392 ++++++++ bots/exp_trans_80/symmetry.py | 116 +++ bots/exp_trans_def/README.md | 19 + bots/exp_trans_def/initialSpawning.py | 120 +++ bots/exp_trans_def/main.py | 1252 +++++++++++++++++++++++++ bots/exp_trans_def/mapPathfinding.py | 392 ++++++++ bots/exp_trans_def/symmetry.py | 116 +++ transition40.zip | Bin 0 -> 19566 bytes 21 files changed, 7572 insertions(+) create mode 100644 bots/exp_trans_50/README.md create mode 100644 bots/exp_trans_50/initialSpawning.py create mode 100644 bots/exp_trans_50/main.py create mode 100644 bots/exp_trans_50/mapPathfinding.py create mode 100644 bots/exp_trans_50/symmetry.py create mode 100644 bots/exp_trans_65/README.md create mode 100644 bots/exp_trans_65/initialSpawning.py create mode 100644 bots/exp_trans_65/main.py create mode 100644 bots/exp_trans_65/mapPathfinding.py create mode 100644 bots/exp_trans_65/symmetry.py create mode 100644 bots/exp_trans_80/README.md create mode 100644 bots/exp_trans_80/initialSpawning.py create mode 100644 bots/exp_trans_80/main.py create mode 100644 bots/exp_trans_80/mapPathfinding.py create mode 100644 bots/exp_trans_80/symmetry.py create mode 100644 bots/exp_trans_def/README.md create mode 100644 bots/exp_trans_def/initialSpawning.py create mode 100644 bots/exp_trans_def/main.py create mode 100644 bots/exp_trans_def/mapPathfinding.py create mode 100644 bots/exp_trans_def/symmetry.py create mode 100644 transition40.zip diff --git a/bots/exp_trans_50/README.md b/bots/exp_trans_50/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_50/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_50/initialSpawning.py b/bots/exp_trans_50/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_50/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_50/main.py b/bots/exp_trans_50/main.py new file mode 100644 index 0000000..797f86c --- /dev/null +++ b/bots/exp_trans_50/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 50 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_50/mapPathfinding.py b/bots/exp_trans_50/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_50/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_50/symmetry.py b/bots/exp_trans_50/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_50/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_65/README.md b/bots/exp_trans_65/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_65/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_65/initialSpawning.py b/bots/exp_trans_65/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_65/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_65/main.py b/bots/exp_trans_65/main.py new file mode 100644 index 0000000..63c77ba --- /dev/null +++ b/bots/exp_trans_65/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 65 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_65/mapPathfinding.py b/bots/exp_trans_65/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_65/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_65/symmetry.py b/bots/exp_trans_65/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_65/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_80/README.md b/bots/exp_trans_80/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_80/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_80/initialSpawning.py b/bots/exp_trans_80/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_80/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_80/main.py b/bots/exp_trans_80/main.py new file mode 100644 index 0000000..d215898 --- /dev/null +++ b/bots/exp_trans_80/main.py @@ -0,0 +1,1244 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 80 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_80/mapPathfinding.py b/bots/exp_trans_80/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_80/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_80/symmetry.py b/bots/exp_trans_80/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_80/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_trans_def/README.md b/bots/exp_trans_def/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_trans_def/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_trans_def/initialSpawning.py b/bots/exp_trans_def/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_trans_def/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_trans_def/main.py b/bots/exp_trans_def/main.py new file mode 100644 index 0000000..c84a739 --- /dev/null +++ b/bots/exp_trans_def/main.py @@ -0,0 +1,1252 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + # UNLESS the core is under attack: v8's unrated scrimmages improved + # against the two teams whose plan this copies (Prompt Engineers 20% -> + # 80%, Orizon 0% -> 20%) and got WORSE against the three that rush our + # core (Pantheon 2-3 -> 0-5, Flotte 2-3 -> 0-5, SmartFridge 2-3 -> 1-4). + # Zeroing the economy states pushes the builders that used to work near + # home out to attack, emptying our half exactly when a rush lands. While + # the recall is up, defence outranks the transition. + if (ct.get_current_round() >= ECON_CUTOFF + and ct.read_store(SLOT_RECALL) != 1): + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_trans_def/mapPathfinding.py b/bots/exp_trans_def/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_trans_def/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_trans_def/symmetry.py b/bots/exp_trans_def/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_trans_def/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/transition40.zip b/transition40.zip new file mode 100644 index 0000000000000000000000000000000000000000..7cd37901fd5ecfc93ff56f874285c9625aae2893 GIT binary patch literal 19566 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 J>IVF^`zO5AkoW)q literal 0 HcmV?d00001 From a4f5595b584ec6855ff9f8e2ca5fc5798660b863 Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 13:41:37 +0200 Subject: [PATCH 12/30] prearmor: seat denial traced from Flotte - neutral vs our lineage, +8 vs rushers The Flotte Experience (#5) beats us 22-3. Traced: 100% of their 28 barriers across 5 games sit within 4 tiles of their OWN core, laid from t4 by a dedicated builder before any threat exists. In the game traced we built ELEVEN gunners from t13 to their eight from t18 and still lost 5-0 - our guns need seats at range 4-6 from their core and those tiles were taken. Our waller walls manhattan 1 with conveyors, which blocks adjacency but not the FIRING SEATS. exp_prearmor adds the seat zone (manhattan 2-4, enemy side only, and only tiles that pass can_fire_from against our core footprint). 3 Ti for 30 HP; our own barriers are passable to our units so it costs no movement. vs shipped v8 (336): 51% (CI 46-57, kills 153-149) - no regression vs spar_rush (168): 60% (CI 52-67, kills 92-55) - v7/v8 both ~52% Not shipped: 51% does not clear the 55% bar I set for autonomous shipping. Building 3.3 barriers/game against Flotte's 5.6, so the mechanism is under-delivering - tuning that next. NOT aegis-v1 or bastion: both were REACTIVE armour built after a threat appeared and were correctly rejected. This is pre-placed, which is the point. Co-Authored-By: Claude Opus 5 --- bots/exp_prearmor/README.md | 19 + bots/exp_prearmor/initialSpawning.py | 120 +++ bots/exp_prearmor/main.py | 1313 ++++++++++++++++++++++++++ bots/exp_prearmor/mapPathfinding.py | 392 ++++++++ bots/exp_prearmor/symmetry.py | 116 +++ 5 files changed, 1960 insertions(+) create mode 100644 bots/exp_prearmor/README.md create mode 100644 bots/exp_prearmor/initialSpawning.py create mode 100644 bots/exp_prearmor/main.py create mode 100644 bots/exp_prearmor/mapPathfinding.py create mode 100644 bots/exp_prearmor/symmetry.py diff --git a/bots/exp_prearmor/README.md b/bots/exp_prearmor/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_prearmor/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_prearmor/initialSpawning.py b/bots/exp_prearmor/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_prearmor/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_prearmor/main.py b/bots/exp_prearmor/main.py new file mode 100644 index 0000000..29a14cf --- /dev/null +++ b/bots/exp_prearmor/main.py @@ -0,0 +1,1313 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# PRE-ARMOUR, measured from The Flotte Experience (#5), who beat us 22-3: +# 100% of their 28 barriers across 5 games sat within 4 tiles of their OWN core, +# laid from t4 by a dedicated builder, before any threat existed. In the game I +# traced we built ELEVEN gunners starting t13 to their eight from t18 and still +# lost 5-0 - our guns need seats at range 4-6 from their core and those tiles +# were occupied. +# Our waller already walls manhattan 1 with conveyors, which blocks adjacency but +# not the FIRING SEATS. This adds the seat zone. 3 Ti for 30 HP is the cheapest +# denial in the game, and per the team's own note our barriers are passable to +# our own units, so it costs us no movement. +# This is NOT aegis-v1 or bastion: both were REACTIVE armour built after a +# threat appeared. This is pre-placed, which is the whole point. +ARMOR_MAX = 6 +ARMOR_MIN_D = 2 +ARMOR_MAX_D = 4 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self._pre_armor(ct, myLoc): + return + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _pre_armor(self, ct: Controller, myLoc: Position) -> bool: + """Deny the enemy their firing seats before they arrive.""" + tc = self.mapPf.teamCore + eg = self._enemy_core_target() + if tc is None or eg is None: + return False + if getattr(self, "_armorBuilt", 0) >= ARMOR_MAX: + return False + core_tiles = [Position(tc.x + dx, tc.y + dy) + for dx in (0, 1) for dy in (0, 1)] + best = None + for x in range(tc.x - ARMOR_MAX_D, tc.x + ARMOR_MAX_D + 2): + for y in range(tc.y - ARMOR_MAX_D, tc.y + ARMOR_MAX_D + 2): + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + seat = Position(x, y) + d = abs(x - tc.x) + abs(y - tc.y) + if not (ARMOR_MIN_D <= d <= ARMOR_MAX_D): + continue + # enemy side only - the home ring is the core's heal access + if abs(x - eg.x) + abs(y - eg.y) > abs(tc.x - eg.x) + abs(tc.y - eg.y): + continue + if not ct.is_in_vision(seat): + continue + if ct.get_tile_building_id(seat) is not None: + continue + # only tiles a gunner could actually shoot our core from + shoots = False + for facing in DIRECTIONS: + for ct_tile in core_tiles: + if ct.can_fire_from(seat, facing, EntityType.GUNNER, ct_tile): + shoots = True + break + if shoots: + break + if not shoots: + continue + key = abs(myLoc.x - x) + abs(myLoc.y - y) + if best is None or key < best[0]: + best = (key, seat) + if best is None: + return False + seat = best[1] + if ct.can_act() and ct.can_build_barrier(seat): + ct.build_barrier(seat) + self._armorBuilt = getattr(self, "_armorBuilt", 0) + 1 + return True + if best[0] > 1: + self.mapPf.moveTo(ct, seat) + return True + return False + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_prearmor/mapPathfinding.py b/bots/exp_prearmor/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_prearmor/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_prearmor/symmetry.py b/bots/exp_prearmor/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_prearmor/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out From 4fcccb128c7fc923ecd4db8b8dc451ba14ebdaf3 Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 14:45:12 +0200 Subject: [PATCH 13/30] flanking and prearmour both rejected; two process failures caught FLANKING - from the Battlecode 2021 postmortem (Stone Tao), whose breakthrough was attacking from multiple directions at once, and confirmed as a real gap here: quadrants covered around the enemy core by guns within 6 tiles is 2.75/4 for the teams that beat us against 1.83/4 for us. Cause is in our code - _direct_siege_plans sorts seats by distance FROM THE BUILDER, so siegers converge on whichever face they approach from. exp_flank adds a quadrant- coverage term ahead of distance. REJECTED: 44% vs v8 (CI 39-50, kills 130-166). Seats in unoccupied quadrants are further away, so more turns go to walking and fewer guns get placed. A mechanism can be real in the data, endorsed by a winning postmortem, and still lose in our engine. PREARMOUR - not shippable. 51% vs v8, and the rush advantage did not replicate: 60% then 52% on a rerun of the identical bot, pooled 187/336 = 56%. Count 10 at radius 4 gives 49%. Two process failures of mine, both caught by redoing the boring version: 1. Swept armour count AND radius together (6/4 -> 10/5, 14/5), so the regression could not be attributed. The clean single-variable test (10/4) showed count alone is not the cause. 2. Built a story on a 60% that a rerun cut to 52%, and compared it against v7's baseline when prearmour is built on v8. The proper control - v8 vs spar_rush at 336 - was never run until now. v8 econ-transition remains live and remains the only confirmed gain. Co-Authored-By: Claude Opus 5 --- bots/exp_flank/README.md | 19 + bots/exp_flank/initialSpawning.py | 120 ++ bots/exp_flank/main.py | 1278 ++++++++++++++++++++ bots/exp_flank/mapPathfinding.py | 392 ++++++ bots/exp_flank/symmetry.py | 116 ++ bots/exp_prearmor_10/README.md | 19 + bots/exp_prearmor_10/initialSpawning.py | 120 ++ bots/exp_prearmor_10/main.py | 1313 +++++++++++++++++++++ bots/exp_prearmor_10/mapPathfinding.py | 392 ++++++ bots/exp_prearmor_10/symmetry.py | 116 ++ bots/exp_prearmor_10r4/README.md | 19 + bots/exp_prearmor_10r4/initialSpawning.py | 120 ++ bots/exp_prearmor_10r4/main.py | 1313 +++++++++++++++++++++ bots/exp_prearmor_10r4/mapPathfinding.py | 392 ++++++ bots/exp_prearmor_10r4/symmetry.py | 116 ++ bots/exp_prearmor_14/README.md | 19 + bots/exp_prearmor_14/initialSpawning.py | 120 ++ bots/exp_prearmor_14/main.py | 1313 +++++++++++++++++++++ bots/exp_prearmor_14/mapPathfinding.py | 392 ++++++ bots/exp_prearmor_14/symmetry.py | 116 ++ 20 files changed, 7805 insertions(+) create mode 100644 bots/exp_flank/README.md create mode 100644 bots/exp_flank/initialSpawning.py create mode 100644 bots/exp_flank/main.py create mode 100644 bots/exp_flank/mapPathfinding.py create mode 100644 bots/exp_flank/symmetry.py create mode 100644 bots/exp_prearmor_10/README.md create mode 100644 bots/exp_prearmor_10/initialSpawning.py create mode 100644 bots/exp_prearmor_10/main.py create mode 100644 bots/exp_prearmor_10/mapPathfinding.py create mode 100644 bots/exp_prearmor_10/symmetry.py create mode 100644 bots/exp_prearmor_10r4/README.md create mode 100644 bots/exp_prearmor_10r4/initialSpawning.py create mode 100644 bots/exp_prearmor_10r4/main.py create mode 100644 bots/exp_prearmor_10r4/mapPathfinding.py create mode 100644 bots/exp_prearmor_10r4/symmetry.py create mode 100644 bots/exp_prearmor_14/README.md create mode 100644 bots/exp_prearmor_14/initialSpawning.py create mode 100644 bots/exp_prearmor_14/main.py create mode 100644 bots/exp_prearmor_14/mapPathfinding.py create mode 100644 bots/exp_prearmor_14/symmetry.py diff --git a/bots/exp_flank/README.md b/bots/exp_flank/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_flank/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_flank/initialSpawning.py b/bots/exp_flank/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_flank/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_flank/main.py b/bots/exp_flank/main.py new file mode 100644 index 0000000..3d94483 --- /dev/null +++ b/bots/exp_flank/main.py @@ -0,0 +1,1278 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# FLANKING. Battlecode 2021 postmortem (Stone Tao): the breakthrough tactic was +# "sending your mucks at the enemy EC from different directions instead of a +# single one" - it splits the defender's attention and denies a single escape +# lane. Measured here, quadrants covered around the enemy core by guns within 6: +# the teams that beat us 2.75 of 4 +# us 1.83 of 4 +# Pantheon vs top teams 2.35-2.50 +# We cluster on one face because _direct_siege_plans sorts seats by distance +# FROM THE BUILDER, so both siegers converge on whichever side they approach +# from. This adds a quadrant-coverage term ahead of distance: a seat in a +# quadrant we do not yet occupy outranks a closer one in a quadrant we do. +FLANK_BONUS = 3 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + @staticmethod + def _quadrant(pos: Position, centre: Position) -> int: + return (0 if pos.x >= centre.x else 1) | (0 if pos.y >= centre.y else 2) + + def _occupied_quadrants(self, ct: Controller, target: Position): + """Which faces of their core our guns already cover.""" + taken = set() + my_team = ct.get_team() + for bid in ct.get_nearby_buildings(): + if ct.get_team(bid) != my_team: + continue + if ct.get_entity_type(bid) not in (EntityType.GUNNER, + EntityType.SENTINEL): + continue + pos = ct.get_position(bid) + if abs(pos.x - target.x) + abs(pos.y - target.y) <= 6: + taken.add(self._quadrant(pos, target)) + return taken + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + occupied = self._occupied_quadrants(ct, target) + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + quad = self._quadrant(seat, target) + key = ( + 0 if diagonal else 1, + FLANK_BONUS if quad in occupied else 0, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_flank/mapPathfinding.py b/bots/exp_flank/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_flank/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_flank/symmetry.py b/bots/exp_flank/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_flank/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_prearmor_10/README.md b/bots/exp_prearmor_10/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_prearmor_10/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_prearmor_10/initialSpawning.py b/bots/exp_prearmor_10/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_prearmor_10/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_prearmor_10/main.py b/bots/exp_prearmor_10/main.py new file mode 100644 index 0000000..0a765e8 --- /dev/null +++ b/bots/exp_prearmor_10/main.py @@ -0,0 +1,1313 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# PRE-ARMOUR, measured from The Flotte Experience (#5), who beat us 22-3: +# 100% of their 28 barriers across 5 games sat within 4 tiles of their OWN core, +# laid from t4 by a dedicated builder, before any threat existed. In the game I +# traced we built ELEVEN gunners starting t13 to their eight from t18 and still +# lost 5-0 - our guns need seats at range 4-6 from their core and those tiles +# were occupied. +# Our waller already walls manhattan 1 with conveyors, which blocks adjacency but +# not the FIRING SEATS. This adds the seat zone. 3 Ti for 30 HP is the cheapest +# denial in the game, and per the team's own note our barriers are passable to +# our own units, so it costs us no movement. +# This is NOT aegis-v1 or bastion: both were REACTIVE armour built after a +# threat appeared. This is pre-placed, which is the whole point. +ARMOR_MAX = 10 +ARMOR_MIN_D = 2 +ARMOR_MAX_D = 5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self._pre_armor(ct, myLoc): + return + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _pre_armor(self, ct: Controller, myLoc: Position) -> bool: + """Deny the enemy their firing seats before they arrive.""" + tc = self.mapPf.teamCore + eg = self._enemy_core_target() + if tc is None or eg is None: + return False + if getattr(self, "_armorBuilt", 0) >= ARMOR_MAX: + return False + core_tiles = [Position(tc.x + dx, tc.y + dy) + for dx in (0, 1) for dy in (0, 1)] + best = None + for x in range(tc.x - ARMOR_MAX_D, tc.x + ARMOR_MAX_D + 2): + for y in range(tc.y - ARMOR_MAX_D, tc.y + ARMOR_MAX_D + 2): + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + seat = Position(x, y) + d = abs(x - tc.x) + abs(y - tc.y) + if not (ARMOR_MIN_D <= d <= ARMOR_MAX_D): + continue + # enemy side only - the home ring is the core's heal access + if abs(x - eg.x) + abs(y - eg.y) > abs(tc.x - eg.x) + abs(tc.y - eg.y): + continue + if not ct.is_in_vision(seat): + continue + if ct.get_tile_building_id(seat) is not None: + continue + # only tiles a gunner could actually shoot our core from + shoots = False + for facing in DIRECTIONS: + for ct_tile in core_tiles: + if ct.can_fire_from(seat, facing, EntityType.GUNNER, ct_tile): + shoots = True + break + if shoots: + break + if not shoots: + continue + key = abs(myLoc.x - x) + abs(myLoc.y - y) + if best is None or key < best[0]: + best = (key, seat) + if best is None: + return False + seat = best[1] + if ct.can_act() and ct.can_build_barrier(seat): + ct.build_barrier(seat) + self._armorBuilt = getattr(self, "_armorBuilt", 0) + 1 + return True + if best[0] > 1: + self.mapPf.moveTo(ct, seat) + return True + return False + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_prearmor_10/mapPathfinding.py b/bots/exp_prearmor_10/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_prearmor_10/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_prearmor_10/symmetry.py b/bots/exp_prearmor_10/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_prearmor_10/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_prearmor_10r4/README.md b/bots/exp_prearmor_10r4/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_prearmor_10r4/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_prearmor_10r4/initialSpawning.py b/bots/exp_prearmor_10r4/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_prearmor_10r4/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_prearmor_10r4/main.py b/bots/exp_prearmor_10r4/main.py new file mode 100644 index 0000000..e917076 --- /dev/null +++ b/bots/exp_prearmor_10r4/main.py @@ -0,0 +1,1313 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# PRE-ARMOUR, measured from The Flotte Experience (#5), who beat us 22-3: +# 100% of their 28 barriers across 5 games sat within 4 tiles of their OWN core, +# laid from t4 by a dedicated builder, before any threat existed. In the game I +# traced we built ELEVEN gunners starting t13 to their eight from t18 and still +# lost 5-0 - our guns need seats at range 4-6 from their core and those tiles +# were occupied. +# Our waller already walls manhattan 1 with conveyors, which blocks adjacency but +# not the FIRING SEATS. This adds the seat zone. 3 Ti for 30 HP is the cheapest +# denial in the game, and per the team's own note our barriers are passable to +# our own units, so it costs us no movement. +# This is NOT aegis-v1 or bastion: both were REACTIVE armour built after a +# threat appeared. This is pre-placed, which is the whole point. +ARMOR_MAX = 10 +ARMOR_MIN_D = 2 +ARMOR_MAX_D = 4 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self._pre_armor(ct, myLoc): + return + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _pre_armor(self, ct: Controller, myLoc: Position) -> bool: + """Deny the enemy their firing seats before they arrive.""" + tc = self.mapPf.teamCore + eg = self._enemy_core_target() + if tc is None or eg is None: + return False + if getattr(self, "_armorBuilt", 0) >= ARMOR_MAX: + return False + core_tiles = [Position(tc.x + dx, tc.y + dy) + for dx in (0, 1) for dy in (0, 1)] + best = None + for x in range(tc.x - ARMOR_MAX_D, tc.x + ARMOR_MAX_D + 2): + for y in range(tc.y - ARMOR_MAX_D, tc.y + ARMOR_MAX_D + 2): + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + seat = Position(x, y) + d = abs(x - tc.x) + abs(y - tc.y) + if not (ARMOR_MIN_D <= d <= ARMOR_MAX_D): + continue + # enemy side only - the home ring is the core's heal access + if abs(x - eg.x) + abs(y - eg.y) > abs(tc.x - eg.x) + abs(tc.y - eg.y): + continue + if not ct.is_in_vision(seat): + continue + if ct.get_tile_building_id(seat) is not None: + continue + # only tiles a gunner could actually shoot our core from + shoots = False + for facing in DIRECTIONS: + for ct_tile in core_tiles: + if ct.can_fire_from(seat, facing, EntityType.GUNNER, ct_tile): + shoots = True + break + if shoots: + break + if not shoots: + continue + key = abs(myLoc.x - x) + abs(myLoc.y - y) + if best is None or key < best[0]: + best = (key, seat) + if best is None: + return False + seat = best[1] + if ct.can_act() and ct.can_build_barrier(seat): + ct.build_barrier(seat) + self._armorBuilt = getattr(self, "_armorBuilt", 0) + 1 + return True + if best[0] > 1: + self.mapPf.moveTo(ct, seat) + return True + return False + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_prearmor_10r4/mapPathfinding.py b/bots/exp_prearmor_10r4/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_prearmor_10r4/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_prearmor_10r4/symmetry.py b/bots/exp_prearmor_10r4/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_prearmor_10r4/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_prearmor_14/README.md b/bots/exp_prearmor_14/README.md new file mode 100644 index 0000000..0905cdd --- /dev/null +++ b/bots/exp_prearmor_14/README.md @@ -0,0 +1,19 @@ +# exp_siege_on_sight (Variant B — SHIPPED as live v2, 2026-08-01 17:20) + +Parent: `bots/generalist-v3`. The siege dispatch gate stops being a round +number and becomes state: enemy core located AND titanium above the floor. +Both are true from roughly t5 via the symmetry tracker. The titanium floor is +unchanged, so the siege is earlier, not cheaper. + +Variant A (`exp_early_siege`) moved the same gate 45 -> 25 and bought 26 turns. +The rest of the gap is TRAVEL — siegers start at home and walk 25-30 tiles. + +| gate | result | core kills | +|---|---|---| +| vs generalist-v3 (336) | **67%** (CI 61-71) | 211-91 | +| vs exp_early_siege (168) | 63% (CI 56-70) | 96-53 | +| vs spar_rush (168) | 52% (CI 45-60) | 76-69 | + +For scale, the parent scores 39% against `spar_rush` (kills 58-91). +First gun on the enemy core: t52 median, reaching t18-t22 on some maps. +Packaged byte-for-byte as `siege-on-sight.zip`. diff --git a/bots/exp_prearmor_14/initialSpawning.py b/bots/exp_prearmor_14/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_prearmor_14/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_prearmor_14/main.py b/bots/exp_prearmor_14/main.py new file mode 100644 index 0000000..cd32e3c --- /dev/null +++ b/bots/exp_prearmor_14/main.py @@ -0,0 +1,1313 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# PRE-ARMOUR, measured from The Flotte Experience (#5), who beat us 22-3: +# 100% of their 28 barriers across 5 games sat within 4 tiles of their OWN core, +# laid from t4 by a dedicated builder, before any threat existed. In the game I +# traced we built ELEVEN gunners starting t13 to their eight from t18 and still +# lost 5-0 - our guns need seats at range 4-6 from their core and those tiles +# were occupied. +# Our waller already walls manhattan 1 with conveyors, which blocks adjacency but +# not the FIRING SEATS. This adds the seat zone. 3 Ti for 30 HP is the cheapest +# denial in the game, and per the team's own note our barriers are passable to +# our own units, so it costs us no movement. +# This is NOT aegis-v1 or bastion: both were REACTIVE armour built after a +# threat appeared. This is pre-placed, which is the whole point. +ARMOR_MAX = 14 +ARMOR_MIN_D = 2 +ARMOR_MAX_D = 5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self._pre_armor(ct, myLoc): + return + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _pre_armor(self, ct: Controller, myLoc: Position) -> bool: + """Deny the enemy their firing seats before they arrive.""" + tc = self.mapPf.teamCore + eg = self._enemy_core_target() + if tc is None or eg is None: + return False + if getattr(self, "_armorBuilt", 0) >= ARMOR_MAX: + return False + core_tiles = [Position(tc.x + dx, tc.y + dy) + for dx in (0, 1) for dy in (0, 1)] + best = None + for x in range(tc.x - ARMOR_MAX_D, tc.x + ARMOR_MAX_D + 2): + for y in range(tc.y - ARMOR_MAX_D, tc.y + ARMOR_MAX_D + 2): + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + seat = Position(x, y) + d = abs(x - tc.x) + abs(y - tc.y) + if not (ARMOR_MIN_D <= d <= ARMOR_MAX_D): + continue + # enemy side only - the home ring is the core's heal access + if abs(x - eg.x) + abs(y - eg.y) > abs(tc.x - eg.x) + abs(tc.y - eg.y): + continue + if not ct.is_in_vision(seat): + continue + if ct.get_tile_building_id(seat) is not None: + continue + # only tiles a gunner could actually shoot our core from + shoots = False + for facing in DIRECTIONS: + for ct_tile in core_tiles: + if ct.can_fire_from(seat, facing, EntityType.GUNNER, ct_tile): + shoots = True + break + if shoots: + break + if not shoots: + continue + key = abs(myLoc.x - x) + abs(myLoc.y - y) + if best is None or key < best[0]: + best = (key, seat) + if best is None: + return False + seat = best[1] + if ct.can_act() and ct.can_build_barrier(seat): + ct.build_barrier(seat) + self._armorBuilt = getattr(self, "_armorBuilt", 0) + 1 + return True + if best[0] > 1: + self.mapPf.moveTo(ct, seat) + return True + return False + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_prearmor_14/mapPathfinding.py b/bots/exp_prearmor_14/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_prearmor_14/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_prearmor_14/symmetry.py b/bots/exp_prearmor_14/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_prearmor_14/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out From d827b7aa5be40269dd9a2ef06ed00e6a6101d5f8 Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 17:30:47 +0200 Subject: [PATCH 14/30] add every local bot folder The remaining experiment, sparring, frozen and concept bots that were never committed - exp_* variants, spar partners, trace twins, and the older lineages. Keeps the whole fleet reproducible alongside the gate results and MEASUREMENT-FINDINGS.md rather than living only on one machine. Co-Authored-By: Claude Opus 5 --- bots/core-sniper-v1/initialSpawning.py | 120 ++ bots/core-sniper-v1/main.py | 702 ++++++++ bots/core-sniper-v1/mapPathfinding.py | 392 +++++ bots/core-sniper-v1/symmetry.py | 116 ++ bots/exp_adaptive_siege/initialSpawning.py | 120 ++ bots/exp_adaptive_siege/main.py | 961 +++++++++++ bots/exp_adaptive_siege/mapPathfinding.py | 392 +++++ bots/exp_adaptive_siege/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_bounded_home_counter/main.py | 1168 +++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_bounded_home_counter/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_bounded_home_counter_trace/main.py | 1214 +++++++++++++ .../mapPathfinding.py | 392 +++++ .../symmetry.py | 116 ++ bots/exp_connected_economy/README.md | 51 + bots/exp_connected_economy/initialSpawning.py | 120 ++ bots/exp_connected_economy/main.py | 1294 ++++++++++++++ bots/exp_connected_economy/mapPathfinding.py | 567 ++++++ bots/exp_connected_economy/symmetry.py | 116 ++ bots/exp_connected_economy_trace/README.md | 44 + .../initialSpawning.py | 120 ++ bots/exp_connected_economy_trace/main.py | 1272 ++++++++++++++ .../mapPathfinding.py | 558 ++++++ bots/exp_connected_economy_trace/symmetry.py | 116 ++ bots/exp_connected_economy_trace2/README.md | 45 + .../initialSpawning.py | 120 ++ bots/exp_connected_economy_trace2/main.py | 1304 ++++++++++++++ .../mapPathfinding.py | 558 ++++++ bots/exp_connected_economy_trace2/symmetry.py | 116 ++ bots/exp_core_sniper/initialSpawning.py | 120 ++ bots/exp_core_sniper/main.py | 702 ++++++++ bots/exp_core_sniper/mapPathfinding.py | 392 +++++ bots/exp_core_sniper/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_emergency_countertrade/main.py | 1175 +++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_emergency_countertrade/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_emergency_countertrade_trace/main.py | 1208 +++++++++++++ .../mapPathfinding.py | 392 +++++ .../symmetry.py | 116 ++ bots/exp_generalist_stack/initialSpawning.py | 120 ++ bots/exp_generalist_stack/main.py | 1093 ++++++++++++ bots/exp_generalist_stack/mapPathfinding.py | 392 +++++ bots/exp_generalist_stack/symmetry.py | 116 ++ bots/exp_healer_intercept/initialSpawning.py | 120 ++ bots/exp_healer_intercept/main.py | 1247 ++++++++++++++ bots/exp_healer_intercept/mapPathfinding.py | 392 +++++ bots/exp_healer_intercept/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_healer_intercept_trace/main.py | 1271 ++++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_healer_intercept_trace/symmetry.py | 116 ++ .../exp_healer_risk_parity/initialSpawning.py | 120 ++ bots/exp_healer_risk_parity/main.py | 1273 ++++++++++++++ bots/exp_healer_risk_parity/mapPathfinding.py | 392 +++++ bots/exp_healer_risk_parity/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_healer_risk_parity_trace/main.py | 1297 ++++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_healer_risk_parity_trace/symmetry.py | 116 ++ .../exp_healer_suppression/initialSpawning.py | 120 ++ bots/exp_healer_suppression/main.py | 1194 +++++++++++++ bots/exp_healer_suppression/mapPathfinding.py | 392 +++++ bots/exp_healer_suppression/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_healer_suppression_sticky/main.py | 1226 +++++++++++++ .../mapPathfinding.py | 392 +++++ .../exp_healer_suppression_sticky/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ .../main.py | 1250 ++++++++++++++ .../mapPathfinding.py | 392 +++++ .../symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_healer_suppression_trace/main.py | 1218 +++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_healer_suppression_trace/symmetry.py | 116 ++ bots/exp_late_route_repair/README.md | 26 + bots/exp_late_route_repair/initialSpawning.py | 120 ++ bots/exp_late_route_repair/main.py | 1332 +++++++++++++++ bots/exp_late_route_repair/mapPathfinding.py | 643 +++++++ bots/exp_late_route_repair/symmetry.py | 116 ++ bots/exp_local_responder/initialSpawning.py | 120 ++ bots/exp_local_responder/main.py | 945 ++++++++++ bots/exp_local_responder/mapPathfinding.py | 392 +++++ bots/exp_local_responder/symmetry.py | 116 ++ bots/exp_meta_armor_rebuild/README.md | 10 + .../exp_meta_armor_rebuild/initialSpawning.py | 120 ++ bots/exp_meta_armor_rebuild/main.py | 1365 +++++++++++++++ bots/exp_meta_armor_rebuild/mapPathfinding.py | 392 +++++ bots/exp_meta_armor_rebuild/symmetry.py | 116 ++ bots/exp_meta_breacher/README.md | 10 + bots/exp_meta_breacher/initialSpawning.py | 120 ++ bots/exp_meta_breacher/main.py | 1295 ++++++++++++++ bots/exp_meta_breacher/mapPathfinding.py | 392 +++++ bots/exp_meta_breacher/symmetry.py | 116 ++ bots/exp_meta_builder_targeting/README.md | 11 + .../initialSpawning.py | 120 ++ bots/exp_meta_builder_targeting/main.py | 1252 ++++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_meta_builder_targeting/symmetry.py | 116 ++ bots/exp_meta_emergency_ammo/README.md | 6 + .../initialSpawning.py | 120 ++ bots/exp_meta_emergency_ammo/main.py | 1261 ++++++++++++++ .../exp_meta_emergency_ammo/mapPathfinding.py | 392 +++++ bots/exp_meta_emergency_ammo/symmetry.py | 116 ++ bots/exp_meta_gunner_aim/README.md | 6 + bots/exp_meta_gunner_aim/initialSpawning.py | 120 ++ bots/exp_meta_gunner_aim/main.py | 1260 ++++++++++++++ bots/exp_meta_gunner_aim/mapPathfinding.py | 392 +++++ bots/exp_meta_gunner_aim/symmetry.py | 116 ++ bots/exp_meta_gunner_defense/README.md | 12 + .../initialSpawning.py | 120 ++ bots/exp_meta_gunner_defense/main.py | 1284 ++++++++++++++ .../exp_meta_gunner_defense/mapPathfinding.py | 392 +++++ bots/exp_meta_gunner_defense/symmetry.py | 116 ++ bots/exp_meta_late_reinforce/README.md | 13 + .../initialSpawning.py | 120 ++ bots/exp_meta_late_reinforce/main.py | 1260 ++++++++++++++ .../exp_meta_late_reinforce/mapPathfinding.py | 392 +++++ bots/exp_meta_late_reinforce/symmetry.py | 116 ++ bots/exp_meta_reinforce/README.md | 14 + bots/exp_meta_reinforce/initialSpawning.py | 120 ++ bots/exp_meta_reinforce/main.py | 1292 ++++++++++++++ bots/exp_meta_reinforce/mapPathfinding.py | 392 +++++ bots/exp_meta_reinforce/symmetry.py | 116 ++ bots/exp_roles/initialSpawning.py | 120 ++ bots/exp_roles/main.py | 624 +++++++ bots/exp_roles/mapPathfinding.py | 392 +++++ bots/exp_roles/symmetry.py | 116 ++ .../initialSpawning.py | 120 ++ bots/exp_sequential_responder/main.py | 1109 ++++++++++++ .../mapPathfinding.py | 392 +++++ bots/exp_sequential_responder/symmetry.py | 116 ++ bots/exp_spawn_discipline/initialSpawning.py | 120 ++ bots/exp_spawn_discipline/main.py | 719 ++++++++ bots/exp_spawn_discipline/mapPathfinding.py | 392 +++++ bots/exp_spawn_discipline/symmetry.py | 116 ++ bots/exp_surgical_counter/initialSpawning.py | 120 ++ bots/exp_surgical_counter/main.py | 867 ++++++++++ bots/exp_surgical_counter/mapPathfinding.py | 392 +++++ bots/exp_surgical_counter/symmetry.py | 116 ++ bots/exp_symmetry/initialSpawning.py | 120 ++ bots/exp_symmetry/main.py | 609 +++++++ bots/exp_symmetry/mapPathfinding.py | 392 +++++ bots/exp_symmetry/symmetry.py | 116 ++ bots/meta-generalist-v1/README.md | 19 + bots/meta-generalist-v1/initialSpawning.py | 120 ++ bots/meta-generalist-v1/main.py | 1252 ++++++++++++++ bots/meta-generalist-v1/mapPathfinding.py | 392 +++++ bots/meta-generalist-v1/symmetry.py | 116 ++ bots/probe_order/main.py | 39 + bots/spar_barrier_fort/README.md | 6 + bots/spar_barrier_fort/main.py | 1515 +++++++++++++++++ bots/spar_barrier_fort/mapanalysis.py | 777 +++++++++ bots/spar_barrier_fort/mapinfo.py | 301 ++++ bots/spar_barrier_fort/path.py | 173 ++ bots/spar_barrier_fort/planes.py | 99 ++ bots/spar_barrier_fort/symmetry.py | 116 ++ 161 files changed, 65826 insertions(+) create mode 100644 bots/core-sniper-v1/initialSpawning.py create mode 100644 bots/core-sniper-v1/main.py create mode 100644 bots/core-sniper-v1/mapPathfinding.py create mode 100644 bots/core-sniper-v1/symmetry.py create mode 100644 bots/exp_adaptive_siege/initialSpawning.py create mode 100644 bots/exp_adaptive_siege/main.py create mode 100644 bots/exp_adaptive_siege/mapPathfinding.py create mode 100644 bots/exp_adaptive_siege/symmetry.py create mode 100644 bots/exp_bounded_home_counter/initialSpawning.py create mode 100644 bots/exp_bounded_home_counter/main.py create mode 100644 bots/exp_bounded_home_counter/mapPathfinding.py create mode 100644 bots/exp_bounded_home_counter/symmetry.py create mode 100644 bots/exp_bounded_home_counter_trace/initialSpawning.py create mode 100644 bots/exp_bounded_home_counter_trace/main.py create mode 100644 bots/exp_bounded_home_counter_trace/mapPathfinding.py create mode 100644 bots/exp_bounded_home_counter_trace/symmetry.py create mode 100644 bots/exp_connected_economy/README.md create mode 100644 bots/exp_connected_economy/initialSpawning.py create mode 100644 bots/exp_connected_economy/main.py create mode 100644 bots/exp_connected_economy/mapPathfinding.py create mode 100644 bots/exp_connected_economy/symmetry.py create mode 100644 bots/exp_connected_economy_trace/README.md create mode 100644 bots/exp_connected_economy_trace/initialSpawning.py create mode 100644 bots/exp_connected_economy_trace/main.py create mode 100644 bots/exp_connected_economy_trace/mapPathfinding.py create mode 100644 bots/exp_connected_economy_trace/symmetry.py create mode 100644 bots/exp_connected_economy_trace2/README.md create mode 100644 bots/exp_connected_economy_trace2/initialSpawning.py create mode 100644 bots/exp_connected_economy_trace2/main.py create mode 100644 bots/exp_connected_economy_trace2/mapPathfinding.py create mode 100644 bots/exp_connected_economy_trace2/symmetry.py create mode 100644 bots/exp_core_sniper/initialSpawning.py create mode 100644 bots/exp_core_sniper/main.py create mode 100644 bots/exp_core_sniper/mapPathfinding.py create mode 100644 bots/exp_core_sniper/symmetry.py create mode 100644 bots/exp_emergency_countertrade/initialSpawning.py create mode 100644 bots/exp_emergency_countertrade/main.py create mode 100644 bots/exp_emergency_countertrade/mapPathfinding.py create mode 100644 bots/exp_emergency_countertrade/symmetry.py create mode 100644 bots/exp_emergency_countertrade_trace/initialSpawning.py create mode 100644 bots/exp_emergency_countertrade_trace/main.py create mode 100644 bots/exp_emergency_countertrade_trace/mapPathfinding.py create mode 100644 bots/exp_emergency_countertrade_trace/symmetry.py create mode 100644 bots/exp_generalist_stack/initialSpawning.py create mode 100644 bots/exp_generalist_stack/main.py create mode 100644 bots/exp_generalist_stack/mapPathfinding.py create mode 100644 bots/exp_generalist_stack/symmetry.py create mode 100644 bots/exp_healer_intercept/initialSpawning.py create mode 100644 bots/exp_healer_intercept/main.py create mode 100644 bots/exp_healer_intercept/mapPathfinding.py create mode 100644 bots/exp_healer_intercept/symmetry.py create mode 100644 bots/exp_healer_intercept_trace/initialSpawning.py create mode 100644 bots/exp_healer_intercept_trace/main.py create mode 100644 bots/exp_healer_intercept_trace/mapPathfinding.py create mode 100644 bots/exp_healer_intercept_trace/symmetry.py create mode 100644 bots/exp_healer_risk_parity/initialSpawning.py create mode 100644 bots/exp_healer_risk_parity/main.py create mode 100644 bots/exp_healer_risk_parity/mapPathfinding.py create mode 100644 bots/exp_healer_risk_parity/symmetry.py create mode 100644 bots/exp_healer_risk_parity_trace/initialSpawning.py create mode 100644 bots/exp_healer_risk_parity_trace/main.py create mode 100644 bots/exp_healer_risk_parity_trace/mapPathfinding.py create mode 100644 bots/exp_healer_risk_parity_trace/symmetry.py create mode 100644 bots/exp_healer_suppression/initialSpawning.py create mode 100644 bots/exp_healer_suppression/main.py create mode 100644 bots/exp_healer_suppression/mapPathfinding.py create mode 100644 bots/exp_healer_suppression/symmetry.py create mode 100644 bots/exp_healer_suppression_sticky/initialSpawning.py create mode 100644 bots/exp_healer_suppression_sticky/main.py create mode 100644 bots/exp_healer_suppression_sticky/mapPathfinding.py create mode 100644 bots/exp_healer_suppression_sticky/symmetry.py create mode 100644 bots/exp_healer_suppression_sticky_trace/initialSpawning.py create mode 100644 bots/exp_healer_suppression_sticky_trace/main.py create mode 100644 bots/exp_healer_suppression_sticky_trace/mapPathfinding.py create mode 100644 bots/exp_healer_suppression_sticky_trace/symmetry.py create mode 100644 bots/exp_healer_suppression_trace/initialSpawning.py create mode 100644 bots/exp_healer_suppression_trace/main.py create mode 100644 bots/exp_healer_suppression_trace/mapPathfinding.py create mode 100644 bots/exp_healer_suppression_trace/symmetry.py create mode 100644 bots/exp_late_route_repair/README.md create mode 100644 bots/exp_late_route_repair/initialSpawning.py create mode 100644 bots/exp_late_route_repair/main.py create mode 100644 bots/exp_late_route_repair/mapPathfinding.py create mode 100644 bots/exp_late_route_repair/symmetry.py create mode 100644 bots/exp_local_responder/initialSpawning.py create mode 100644 bots/exp_local_responder/main.py create mode 100644 bots/exp_local_responder/mapPathfinding.py create mode 100644 bots/exp_local_responder/symmetry.py create mode 100644 bots/exp_meta_armor_rebuild/README.md create mode 100644 bots/exp_meta_armor_rebuild/initialSpawning.py create mode 100644 bots/exp_meta_armor_rebuild/main.py create mode 100644 bots/exp_meta_armor_rebuild/mapPathfinding.py create mode 100644 bots/exp_meta_armor_rebuild/symmetry.py create mode 100644 bots/exp_meta_breacher/README.md create mode 100644 bots/exp_meta_breacher/initialSpawning.py create mode 100644 bots/exp_meta_breacher/main.py create mode 100644 bots/exp_meta_breacher/mapPathfinding.py create mode 100644 bots/exp_meta_breacher/symmetry.py create mode 100644 bots/exp_meta_builder_targeting/README.md create mode 100644 bots/exp_meta_builder_targeting/initialSpawning.py create mode 100644 bots/exp_meta_builder_targeting/main.py create mode 100644 bots/exp_meta_builder_targeting/mapPathfinding.py create mode 100644 bots/exp_meta_builder_targeting/symmetry.py create mode 100644 bots/exp_meta_emergency_ammo/README.md create mode 100644 bots/exp_meta_emergency_ammo/initialSpawning.py create mode 100644 bots/exp_meta_emergency_ammo/main.py create mode 100644 bots/exp_meta_emergency_ammo/mapPathfinding.py create mode 100644 bots/exp_meta_emergency_ammo/symmetry.py create mode 100644 bots/exp_meta_gunner_aim/README.md create mode 100644 bots/exp_meta_gunner_aim/initialSpawning.py create mode 100644 bots/exp_meta_gunner_aim/main.py create mode 100644 bots/exp_meta_gunner_aim/mapPathfinding.py create mode 100644 bots/exp_meta_gunner_aim/symmetry.py create mode 100644 bots/exp_meta_gunner_defense/README.md create mode 100644 bots/exp_meta_gunner_defense/initialSpawning.py create mode 100644 bots/exp_meta_gunner_defense/main.py create mode 100644 bots/exp_meta_gunner_defense/mapPathfinding.py create mode 100644 bots/exp_meta_gunner_defense/symmetry.py create mode 100644 bots/exp_meta_late_reinforce/README.md create mode 100644 bots/exp_meta_late_reinforce/initialSpawning.py create mode 100644 bots/exp_meta_late_reinforce/main.py create mode 100644 bots/exp_meta_late_reinforce/mapPathfinding.py create mode 100644 bots/exp_meta_late_reinforce/symmetry.py create mode 100644 bots/exp_meta_reinforce/README.md create mode 100644 bots/exp_meta_reinforce/initialSpawning.py create mode 100644 bots/exp_meta_reinforce/main.py create mode 100644 bots/exp_meta_reinforce/mapPathfinding.py create mode 100644 bots/exp_meta_reinforce/symmetry.py create mode 100644 bots/exp_roles/initialSpawning.py create mode 100644 bots/exp_roles/main.py create mode 100644 bots/exp_roles/mapPathfinding.py create mode 100644 bots/exp_roles/symmetry.py create mode 100644 bots/exp_sequential_responder/initialSpawning.py create mode 100644 bots/exp_sequential_responder/main.py create mode 100644 bots/exp_sequential_responder/mapPathfinding.py create mode 100644 bots/exp_sequential_responder/symmetry.py create mode 100644 bots/exp_spawn_discipline/initialSpawning.py create mode 100644 bots/exp_spawn_discipline/main.py create mode 100644 bots/exp_spawn_discipline/mapPathfinding.py create mode 100644 bots/exp_spawn_discipline/symmetry.py create mode 100644 bots/exp_surgical_counter/initialSpawning.py create mode 100644 bots/exp_surgical_counter/main.py create mode 100644 bots/exp_surgical_counter/mapPathfinding.py create mode 100644 bots/exp_surgical_counter/symmetry.py create mode 100644 bots/exp_symmetry/initialSpawning.py create mode 100644 bots/exp_symmetry/main.py create mode 100644 bots/exp_symmetry/mapPathfinding.py create mode 100644 bots/exp_symmetry/symmetry.py create mode 100644 bots/meta-generalist-v1/README.md create mode 100644 bots/meta-generalist-v1/initialSpawning.py create mode 100644 bots/meta-generalist-v1/main.py create mode 100644 bots/meta-generalist-v1/mapPathfinding.py create mode 100644 bots/meta-generalist-v1/symmetry.py create mode 100644 bots/probe_order/main.py create mode 100644 bots/spar_barrier_fort/README.md create mode 100644 bots/spar_barrier_fort/main.py create mode 100644 bots/spar_barrier_fort/mapanalysis.py create mode 100644 bots/spar_barrier_fort/mapinfo.py create mode 100644 bots/spar_barrier_fort/path.py create mode 100644 bots/spar_barrier_fort/planes.py create mode 100644 bots/spar_barrier_fort/symmetry.py diff --git a/bots/core-sniper-v1/initialSpawning.py b/bots/core-sniper-v1/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/core-sniper-v1/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/core-sniper-v1/main.py b/bots/core-sniper-v1/main.py new file mode 100644 index 0000000..7f6db54 --- /dev/null +++ b/bots/core-sniper-v1/main.py @@ -0,0 +1,702 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Walk to an exact gunner firing seat and rebuild it indefinitely.""" + target = self._enemy_core_target() + if target is None: + return + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + best = None + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + + if best is None: + self.mapPf.moveTo(ct, target) + return + + _, seat, stand, facing = best + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/core-sniper-v1/mapPathfinding.py b/bots/core-sniper-v1/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/core-sniper-v1/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/core-sniper-v1/symmetry.py b/bots/core-sniper-v1/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/core-sniper-v1/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_adaptive_siege/initialSpawning.py b/bots/exp_adaptive_siege/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_adaptive_siege/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_adaptive_siege/main.py b/bots/exp_adaptive_siege/main.py new file mode 100644 index 0000000..6e76916 --- /dev/null +++ b/bots/exp_adaptive_siege/main.py @@ -0,0 +1,961 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_1_TARGET = 12 +SLOT_SIEGER_2_TARGET = 13 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +SIEGE_STALL_ROUNDS = 16 +SIEGE_PAUSE_ROUNDS = 32 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def siege_role_counters(role: int, defender_count: int) -> bool: + """Return whether a numbered sieger should switch to counter-battery.""" + return (role == 2 and defender_count >= 1) or defender_count >= 3 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._siegeStall = 0 + self._siegePauseUntil = 0 + self._siegeLastDistance = None + self._siegeLastDefenders = None + self._siegeLastCoreHp = None + self._counterSeat = None + self._counterStand = None + self._counterFacing = None + self._counterTarget = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if self._is_sieger(ct): + if ct.get_current_round() < self._siegePauseUntil: + self._clear_counter_assignment(ct) + elif (ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + if self._run_adaptive_siege(ct, myLoc): + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _sieger_target_slot(self, ct: Controller): + role = self._sieger_role(ct) + if role == 1: + return SLOT_SIEGER_1_TARGET + if role == 2: + return SLOT_SIEGER_2_TARGET + return None + + def _clear_counter_assignment(self, ct: Controller) -> None: + slot = self._sieger_target_slot(ct) + if slot is not None: + ct.write_store(slot, 0) + self._counterSeat = None + self._counterStand = None + self._counterFacing = None + self._counterTarget = None + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _direct_siege_zone(self, ct: Controller, target: Position): + """Visible core-firing seats plus the cardinal stands that service them.""" + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + zone = set() + for core_tile in core_tiles: + for attack_offset in gunnerAttacks: + seat = Position( + core_tile.x - attack_offset.x, + core_tile.y - attack_offset.y, + ) + if not (0 <= seat.x < self.mapW + and 0 <= seat.y < self.mapH + and ct.is_in_vision(seat)): + continue + facing = seat.direction_to(core_tile) + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + continue + zone.add(seat) + for approach in CARDINALS: + stand = seat.add(approach) + if (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH): + zone.add(stand) + return zone + + def _visible_core_defenders(self, ct: Controller, target: Position): + defenders = [] + core_hp = None + my_team = ct.get_team() + siege_zone = self._direct_siege_zone(ct, target) + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + position = ct.get_position(building_id) + if building_type == EntityType.CORE: + core_hp = ct.get_hp(building_id) + elif building_type in (EntityType.GUNNER, EntityType.SENTINEL): + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if siege_zone.intersection(attack_tiles): + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + facing, + )) + defenders.sort() + return defenders, core_hp + + def _update_siege_progress( + self, ct: Controller, objective: Position, + defender_count: int, core_hp) -> bool: + """Return False on the turn that a stalled sieger enters its pause.""" + distance = ct.get_position().distance_squared(objective) + progressed = ( + self._siegeLastDistance is None + or distance < self._siegeLastDistance + or (self._siegeLastDefenders is not None + and defender_count < self._siegeLastDefenders) + or (core_hp is not None and self._siegeLastCoreHp is not None + and core_hp < self._siegeLastCoreHp) + ) + self._siegeLastDistance = distance + self._siegeLastDefenders = defender_count + if core_hp is not None: + self._siegeLastCoreHp = core_hp + if progressed: + self._siegeStall = 0 + else: + self._siegeStall += 1 + if self._siegeStall < SIEGE_STALL_ROUNDS: + return True + self._siegePauseUntil = ct.get_current_round() + SIEGE_PAUSE_ROUNDS + self._siegeStall = 0 + self._siegeLastDistance = None + self._siegeLastDefenders = None + self._siegeLastCoreHp = None + self._clear_counter_assignment(ct) + return False + + def _mark_siege_build(self) -> None: + self._siegeStall = 0 + + def _run_adaptive_siege(self, ct: Controller, myLoc: Position) -> bool: + target = self._enemy_core_target() + if target is None: + self._clear_counter_assignment(ct) + return True + defenders, core_hp = self._visible_core_defenders(ct, target) + role = self._sieger_role(ct) + if siege_role_counters(role, len(defenders)): + target_index = 0 if role == 2 else 1 + selected = defenders[target_index % len(defenders)] + objective = selected[3] + if not self._update_siege_progress( + ct, objective, len(defenders), core_hp): + return False + self._run_counter_battery(ct, myLoc, selected, defenders) + return True + self._clear_counter_assignment(ct) + if not self._update_siege_progress( + ct, target, len(defenders), core_hp): + return False + self._run_direct_core_siege(ct, myLoc, target) + return True + + def _defender_attack_tiles(self, ct: Controller, defenders): + unsafe = set() + for _, _, _, position, building_type, facing in defenders: + unsafe.update(ct.get_attackable_tiles_from( + position, facing, building_type)) + return unsafe + + def _counter_plan(self, ct: Controller, myLoc: Position, + target: Position, defenders): + unsafe = self._defender_attack_tiles(ct, defenders) + best = None + for seat in ct.get_nearby_tiles(): + if seat in unsafe or ct.get_tile_env(seat) != Environment.EMPTY: + continue + building_id = ct.get_tile_building_id(seat) + maintained = seat == self._counterSeat + if building_id is not None: + if not (maintained + and ct.get_team(building_id) == ct.get_team() + and ct.get_entity_type(building_id) + == EntityType.GUNNER): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if maintained else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _run_counter_battery( + self, ct: Controller, myLoc: Position, selected, defenders) -> None: + target = selected[3] + target_slot = self._sieger_target_slot(ct) + if target_slot is not None: + ct.write_store(target_slot, pack_position(target)) + + target_changed = self._counterTarget != target + if target_changed: + self._counterSeat = None + self._counterStand = None + self._counterFacing = None + self._counterTarget = target + + if self._counterSeat is not None: + gunner_id = ct.get_tile_building_id(self._counterSeat) + if (gunner_id is not None + and ct.get_team(gunner_id) == ct.get_team() + and ct.get_entity_type(gunner_id) == EntityType.GUNNER): + if (ct.get_hp(gunner_id) < ct.get_max_hp(gunner_id) + and ct.can_heal(self._counterSeat)): + ct.heal(self._counterSeat) + return + + plan = self._counter_plan(ct, myLoc, target, defenders) + if plan is None: + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plan + self._counterSeat = seat + self._counterStand = stand + self._counterFacing = facing + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + self._mark_siege_build() + + def _run_direct_core_siege( + self, ct: Controller, myLoc: Position, target: Position) -> None: + """Walk to an exact gunner firing seat and rebuild it indefinitely.""" + target = self._enemy_core_target() + if target is None: + return + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + best = None + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + + if best is None: + self.mapPf.moveTo(ct, target) + return + + _, seat, stand, facing = best + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + self._mark_siege_build() + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_adaptive_siege/mapPathfinding.py b/bots/exp_adaptive_siege/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_adaptive_siege/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_adaptive_siege/symmetry.py b/bots/exp_adaptive_siege/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_adaptive_siege/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_bounded_home_counter/initialSpawning.py b/bots/exp_bounded_home_counter/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_bounded_home_counter/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_bounded_home_counter/main.py b/bots/exp_bounded_home_counter/main.py new file mode 100644 index 0000000..15656da --- /dev/null +++ b/bots/exp_bounded_home_counter/main.py @@ -0,0 +1,1168 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_bounded_home_counter/mapPathfinding.py b/bots/exp_bounded_home_counter/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_bounded_home_counter/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_bounded_home_counter/symmetry.py b/bots/exp_bounded_home_counter/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_bounded_home_counter/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_bounded_home_counter_trace/initialSpawning.py b/bots/exp_bounded_home_counter_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_bounded_home_counter_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_bounded_home_counter_trace/main.py b/bots/exp_bounded_home_counter_trace/main.py new file mode 100644 index 0000000..baeb654 --- /dev/null +++ b/bots/exp_bounded_home_counter_trace/main.py @@ -0,0 +1,1214 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HOME_COUNTER_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/v89-home-counter-traces/bounded_trace.log" +) + + +def home_counter_trace(*parts): + with open(HOME_COUNTER_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + "\n") + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded = plan is not None + if bounded: + home_counter_trace( + "BOUNDED_PLAN", + ct.get_current_round(), + ct.get_id(), + len(turret_attacks), + threat.x, + threat.y, + myLoc.x, + myLoc.y, + plan[0].x, + plan[0].y, + plan[1].x, + plan[1].y, + ct.get_global_resources(), + ) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + if bounded: + home_counter_trace( + "BOUNDED_BUILD", + ct.get_current_round(), + ct.get_id(), + threat.x, + threat.y, + seat.x, + seat.y, + ) + elif bounded: + home_counter_trace( + "BOUNDED_BLOCKED", + ct.get_current_round(), + ct.get_id(), + int(ct.can_act()), + int(ct.can_build_gunner(seat, facing)), + ct.get_global_resources(), + ) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_bounded_home_counter_trace/mapPathfinding.py b/bots/exp_bounded_home_counter_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_bounded_home_counter_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_bounded_home_counter_trace/symmetry.py b/bots/exp_bounded_home_counter_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_bounded_home_counter_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_connected_economy/README.md b/bots/exp_connected_economy/README.md new file mode 100644 index 0000000..53e4e15 --- /dev/null +++ b/bots/exp_connected_economy/README.md @@ -0,0 +1,51 @@ +# Connected Economy experiment + +**Rejected.** On production engine 2.3.3 the broad rewrite failed the first +12-game parent crash screen at 3-9 (25%), with core kills 3-9. It changed the +opening routing surface too much. Preserve this folder as evidence; the next +experiment must leave all pre-round-80 parent routing unchanged and add only a +late orphan-chain repair. + +Parent: frozen `meta-generalist-v1`, which the user reports is on the live +ladder. The parent source and ZIP hashes matched `meta_generalist_v1_results.json` +before this folder was created. + +## Observed general failure + +Long games can contain many conveyors while one or more harvesters never feed +the core. The parent counts any adjacent friendly conveyor as a harvester +connection, treats any visible conveyor as a routing sink without following its +facing, and disables all route completion at round 40. Defensive wall conveyors +can therefore hide orphan harvesters or attract economic paths into dead ends. + +In the three latest local parent-line smoke replays, one of three harvesters was +still unserved at the round-40 transition and remained unserved at game end +despite 18-23 living conveyors. + +## Independent mechanism + +- Remember observed friendly conveyor facings and remove observations when a + tile is revisited without that conveyor. +- Prove core-connected conveyors by following actual output directions to the + core in a fixed point. Unknown, wrong-facing, cyclic, and dead-end components + are not merge targets. +- A harvester is served only by an adjacent chain root whose output points away + from it and whose chain is proven connected. +- Extend the actual output of orphan chains; fresh links route only to the core + or a proven connected trunk. +- Preserve the round-40 economy-to-pressure transition. From round 80, allow + one locally nearest builder to spend otherwise sub-siege reserves on a + bounded repair of an already-built harvester/chain, at no more than the + parent's original route priority. Do not discover new ore or start broad + economic expansion. +- Do not use map names, dimensions, opponent identity, or store protocol + changes. Root sources and the active package remain unchanged. + +## Acceptance evidence + +Deterministic tests must cover connected trunks, wrong-facing adjacency, +disconnected merge rejection, cycles, stale-observation cleanup, dead-end +extension, and the cutoff boundary. Replay audits must show fewer unserved +harvesters/disconnected conveyors. Graduated gates must preserve core-kill +pressure against the frozen parent, `spar_rush`, and unrelated bot families; +promotion requires a multi-opponent gain, not one long map. diff --git a/bots/exp_connected_economy/initialSpawning.py b/bots/exp_connected_economy/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_connected_economy/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_connected_economy/main.py b/bots/exp_connected_economy/main.py new file mode 100644 index 0000000..e722b9c --- /dev/null +++ b/bots/exp_connected_economy/main.py @@ -0,0 +1,1294 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 80 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + """Allow a short, funded repair to an already-built economic asset.""" + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer(candidates, target: Position, excluded_ids=()): + """One local builder owns a late repair: nearest, then stable entity ID.""" + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: (item[1].distance_squared(target), item[0]), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + return choose_route_repairer( + candidates, target, excluded) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # proven orphan harvesters + their unfinished chains + routePos = None + routeDir = None + routeLinks = 4096 + teamCore = self.mapPf.teamCore + if teamCore is not None: + for harvester, endTile, started, remainingLinks in ( + self.mapPf.economyRouteCandidates(ct)): + # Continuing an economic chain remains slightly more urgent + # than starting its first link, preserving the parent's score + # ordering while removing unrelated defensive dead ends. + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + bScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = Direction.CENTRE + routeLinks = remainingLinks + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the pressure transition. Much later, an idle and well-funded + # builder may finish a bounded route to an already-built harvester; it + # may not discover new ore or override any visible enemy. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + if late_route_repair_allowed( + currentRound, ct.get_global_resources(), routeLinks + ) and self._owns_late_route_repair(ct, routePos): + # At most the parent's normal route priority: nearby direct + # threats still score up to 10, while low-value stalemate + # targets can yield for the few actions needed to finish flow. + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + else: + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_connected_economy/mapPathfinding.py b/bots/exp_connected_economy/mapPathfinding.py new file mode 100644 index 0000000..a8b644d --- /dev/null +++ b/bots/exp_connected_economy/mapPathfinding.py @@ -0,0 +1,567 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + """Return the four tiles occupied by a core whose position is top-left.""" + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Conveyors whose real output chain reaches the core. + + This deliberately does not use geometric proximity. A wrong-facing link, + dead end, or cycle remains disconnected even when it touches a valid trunk. + """ + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + output = position.add(facing) + if output in core_tiles or output in connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + """Adjacent conveyors which accept from, rather than output into, a mine.""" + roots = [] + for direction in CARDINALS: + position = harvester.add(direction) + facing = conveyor_directions.get(position) + if facing is not None and position.add(facing) != harvester: + roots.append(position) + return roots + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + """Follow facings from root; return (status, first missing output tile).""" + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", None + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + # Per-builder observations, refreshed whenever a tile re-enters vision. + # They make facing-based connectivity possible without consuming store + # slots or encoding map/opponent fingerprints. + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh economy-graph memory for one currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + """Optimistic number of new links to the core/known valid trunk.""" + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def economyRouteCandidates(self, ct: Controller): + """Visible missing links belonging to an observed friendly harvester. + + Defensive conveyors never become work merely because they have an empty + output. Every returned dead end is traced from a real harvester root. + """ + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status == "dead_end" and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if (ct.get_team(endId) != ct.get_team() + or ct.get_entity_type(endId) + != EntityType.BARRIER): + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + # A friendly builder standing on the missing link is ideal: it + # can build a walkable conveyor on its own tile. Generic + # passability reports occupied tiles as blocked, which made the + # parent-style crowd erase route work exactly when it arrived. + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position, connected): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + # A remembered conveyor is traversable as a merge target only when its + # actual facing chain is proven to reach the core. This is the central + # distinction the parent router omitted. + if tile in self.knownTeamConveyors: + if tile not in connected: + return [None, 'stuck'] + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) + != EntityType.CONVEYOR): + return [None, 'stuck'] + if ct.get_stored_resource(tileId) is not None: + return [None, 'stuck'] + return [0, 'done'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + return [None, 'stuck'] # unproven or opposing graph state + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + # Valid empty trunks are optional merge targets. Loaded trunks are + # temporarily excluded because a conveyor can hold only one stack. + for position in connected: + if not (0 <= position.x < w and 0 <= position.y < h): + continue + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) + != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + mergeCost = 8 + if mergeCost < self.conveyorMap[position.x][position.y][0]: + self.conveyorMap[position.x][position.y] = [mergeCost, 'done'] + buckets[mergeCost].append(position) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost( + ct, nextTile, connected) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_connected_economy/symmetry.py b/bots/exp_connected_economy/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_connected_economy/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_connected_economy_trace/README.md b/bots/exp_connected_economy_trace/README.md new file mode 100644 index 0000000..105e84a --- /dev/null +++ b/bots/exp_connected_economy_trace/README.md @@ -0,0 +1,44 @@ +# Connected Economy experiment + +Parent: frozen `meta-generalist-v1`, which the user reports is on the live +ladder. The parent source and ZIP hashes matched `meta_generalist_v1_results.json` +before this folder was created. + +## Observed general failure + +Long games can contain many conveyors while one or more harvesters never feed +the core. The parent counts any adjacent friendly conveyor as a harvester +connection, treats any visible conveyor as a routing sink without following its +facing, and disables all route completion at round 40. Defensive wall conveyors +can therefore hide orphan harvesters or attract economic paths into dead ends. + +In the three latest local parent-line smoke replays, one of three harvesters was +still unserved at the round-40 transition and remained unserved at game end +despite 18-23 living conveyors. + +## Independent mechanism + +- Remember observed friendly conveyor facings and remove observations when a + tile is revisited without that conveyor. +- Prove core-connected conveyors by following actual output directions to the + core in a fixed point. Unknown, wrong-facing, cyclic, and dead-end components + are not merge targets. +- A harvester is served only by an adjacent chain root whose output points away + from it and whose chain is proven connected. +- Extend the actual output of orphan chains; fresh links route only to the core + or a proven connected trunk. +- Preserve the round-40 economy-to-pressure transition. From round 80, allow + only funded, bounded repair of an already-built harvester/chain at no more + than the parent's original route priority; do not discover new ore or start + broad economic expansion. +- Do not use map names, dimensions, opponent identity, or store protocol + changes. Root sources and the active package remain unchanged. + +## Acceptance evidence + +Deterministic tests must cover connected trunks, wrong-facing adjacency, +disconnected merge rejection, cycles, stale-observation cleanup, dead-end +extension, and the cutoff boundary. Replay audits must show fewer unserved +harvesters/disconnected conveyors. Graduated gates must preserve core-kill +pressure against the frozen parent, `spar_rush`, and unrelated bot families; +promotion requires a multi-opponent gain, not one long map. diff --git a/bots/exp_connected_economy_trace/initialSpawning.py b/bots/exp_connected_economy_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_connected_economy_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_connected_economy_trace/main.py b/bots/exp_connected_economy_trace/main.py new file mode 100644 index 0000000..d910ecc --- /dev/null +++ b/bots/exp_connected_economy_trace/main.py @@ -0,0 +1,1272 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 80 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 150 +LATE_ROUTE_SCORE_CAP = 6 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + """Allow a short, funded repair to an already-built economic asset.""" + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # proven orphan harvesters + their unfinished chains + routePos = None + routeDir = None + routeLinks = 4096 + teamCore = self.mapPf.teamCore + if teamCore is not None: + for harvester, endTile, started, remainingLinks in ( + self.mapPf.economyRouteCandidates(ct)): + # Continuing an economic chain remains slightly more urgent + # than starting its first link, preserving the parent's score + # ordering while removing unrelated defensive dead ends. + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + bScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = Direction.CENTRE + routeLinks = remainingLinks + if 78 <= ct.get_current_round() <= 90: + print( + "CE_TRACE", + ct.get_current_round(), ct.get_id(), myLoc, + "waller", self._is_waller(ct), + "sieger", self._sieger_role(ct), + "ti", ct.get_global_resources(), + "attack", attackScore, + "route", routeScore, routePos, routeLinks, + "known", len(self.mapPf.knownTeamHarvesters), + len(self.mapPf.knownTeamConveyors), + len(self.mapPf.connectedConveyors()), + ) + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the pressure transition. Much later, an idle and well-funded + # builder may finish a bounded route to an already-built harvester; it + # may not discover new ore or override any visible enemy. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + if late_route_repair_allowed( + currentRound, ct.get_global_resources(), routeLinks): + # At most the parent's normal route priority: nearby direct + # threats still score up to 10, while low-value stalemate + # targets can yield for the few actions needed to finish flow. + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + else: + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_connected_economy_trace/mapPathfinding.py b/bots/exp_connected_economy_trace/mapPathfinding.py new file mode 100644 index 0000000..d3eb672 --- /dev/null +++ b/bots/exp_connected_economy_trace/mapPathfinding.py @@ -0,0 +1,558 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + """Return the four tiles occupied by a core whose position is top-left.""" + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Conveyors whose real output chain reaches the core. + + This deliberately does not use geometric proximity. A wrong-facing link, + dead end, or cycle remains disconnected even when it touches a valid trunk. + """ + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + output = position.add(facing) + if output in core_tiles or output in connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + """Adjacent conveyors which accept from, rather than output into, a mine.""" + roots = [] + for direction in CARDINALS: + position = harvester.add(direction) + facing = conveyor_directions.get(position) + if facing is not None and position.add(facing) != harvester: + roots.append(position) + return roots + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + """Follow facings from root; return (status, first missing output tile).""" + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", None + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + # Per-builder observations, refreshed whenever a tile re-enters vision. + # They make facing-based connectivity possible without consuming store + # slots or encoding map/opponent fingerprints. + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh economy-graph memory for one currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + """Optimistic number of new links to the core/known valid trunk.""" + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def economyRouteCandidates(self, ct: Controller): + """Visible missing links belonging to an observed friendly harvester. + + Defensive conveyors never become work merely because they have an empty + output. Every returned dead end is traced from a real harvester root. + """ + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status == "dead_end" and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if (ct.get_team(endId) != ct.get_team() + or ct.get_entity_type(endId) + != EntityType.BARRIER): + continue + if not ct.is_tile_passable(end): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position, connected): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + # A remembered conveyor is traversable as a merge target only when its + # actual facing chain is proven to reach the core. This is the central + # distinction the parent router omitted. + if tile in self.knownTeamConveyors: + if tile not in connected: + return [None, 'stuck'] + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) + != EntityType.CONVEYOR): + return [None, 'stuck'] + if ct.get_stored_resource(tileId) is not None: + return [None, 'stuck'] + return [0, 'done'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + return [None, 'stuck'] # unproven or opposing graph state + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + # Valid empty trunks are optional merge targets. Loaded trunks are + # temporarily excluded because a conveyor can hold only one stack. + for position in connected: + if not (0 <= position.x < w and 0 <= position.y < h): + continue + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) + != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + mergeCost = 8 + if mergeCost < self.conveyorMap[position.x][position.y][0]: + self.conveyorMap[position.x][position.y] = [mergeCost, 'done'] + buckets[mergeCost].append(position) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost( + ct, nextTile, connected) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_connected_economy_trace/symmetry.py b/bots/exp_connected_economy_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_connected_economy_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_connected_economy_trace2/README.md b/bots/exp_connected_economy_trace2/README.md new file mode 100644 index 0000000..ca4397e --- /dev/null +++ b/bots/exp_connected_economy_trace2/README.md @@ -0,0 +1,45 @@ +# Connected Economy experiment + +Parent: frozen `meta-generalist-v1`, which the user reports is on the live +ladder. The parent source and ZIP hashes matched `meta_generalist_v1_results.json` +before this folder was created. + +## Observed general failure + +Long games can contain many conveyors while one or more harvesters never feed +the core. The parent counts any adjacent friendly conveyor as a harvester +connection, treats any visible conveyor as a routing sink without following its +facing, and disables all route completion at round 40. Defensive wall conveyors +can therefore hide orphan harvesters or attract economic paths into dead ends. + +In the three latest local parent-line smoke replays, one of three harvesters was +still unserved at the round-40 transition and remained unserved at game end +despite 18-23 living conveyors. + +## Independent mechanism + +- Remember observed friendly conveyor facings and remove observations when a + tile is revisited without that conveyor. +- Prove core-connected conveyors by following actual output directions to the + core in a fixed point. Unknown, wrong-facing, cyclic, and dead-end components + are not merge targets. +- A harvester is served only by an adjacent chain root whose output points away + from it and whose chain is proven connected. +- Extend the actual output of orphan chains; fresh links route only to the core + or a proven connected trunk. +- Preserve the round-40 economy-to-pressure transition. From round 80, allow + one locally nearest builder to spend otherwise sub-siege reserves on a + bounded repair of an already-built harvester/chain, at no more than the + parent's original route priority. Do not discover new ore or start broad + economic expansion. +- Do not use map names, dimensions, opponent identity, or store protocol + changes. Root sources and the active package remain unchanged. + +## Acceptance evidence + +Deterministic tests must cover connected trunks, wrong-facing adjacency, +disconnected merge rejection, cycles, stale-observation cleanup, dead-end +extension, and the cutoff boundary. Replay audits must show fewer unserved +harvesters/disconnected conveyors. Graduated gates must preserve core-kill +pressure against the frozen parent, `spar_rush`, and unrelated bot families; +promotion requires a multi-opponent gain, not one long map. diff --git a/bots/exp_connected_economy_trace2/initialSpawning.py b/bots/exp_connected_economy_trace2/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_connected_economy_trace2/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_connected_economy_trace2/main.py b/bots/exp_connected_economy_trace2/main.py new file mode 100644 index 0000000..4666d63 --- /dev/null +++ b/bots/exp_connected_economy_trace2/main.py @@ -0,0 +1,1304 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 80 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + """Allow a short, funded repair to an already-built economic asset.""" + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer(candidates, target: Position, excluded_ids=()): + """One local builder owns a late repair: nearest, then stable entity ID.""" + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: (item[1].distance_squared(target), item[0]), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + return choose_route_repairer( + candidates, target, excluded) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # proven orphan harvesters + their unfinished chains + routePos = None + routeDir = None + routeLinks = 4096 + teamCore = self.mapPf.teamCore + if teamCore is not None: + for harvester, endTile, started, remainingLinks in ( + self.mapPf.economyRouteCandidates(ct)): + # Continuing an economic chain remains slightly more urgent + # than starting its first link, preserving the parent's score + # ordering while removing unrelated defensive dead ends. + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + bScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = Direction.CENTRE + routeLinks = remainingLinks + if 78 <= ct.get_current_round() <= 100: + owns = ( + routePos is not None + and self._owns_late_route_repair(ct, routePos) + ) + print( + "CE2_TRACE", ct.get_current_round(), ct.get_id(), myLoc, + "ti", ct.get_global_resources(), "attack", attackScore, + "route", routeScore, routePos, routeLinks, "owns", owns, + ) + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the pressure transition. Much later, an idle and well-funded + # builder may finish a bounded route to an already-built harvester; it + # may not discover new ore or override any visible enemy. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + if late_route_repair_allowed( + currentRound, ct.get_global_resources(), routeLinks + ) and self._owns_late_route_repair(ct, routePos): + # At most the parent's normal route priority: nearby direct + # threats still score up to 10, while low-value stalemate + # targets can yield for the few actions needed to finish flow. + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + else: + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_connected_economy_trace2/mapPathfinding.py b/bots/exp_connected_economy_trace2/mapPathfinding.py new file mode 100644 index 0000000..d3eb672 --- /dev/null +++ b/bots/exp_connected_economy_trace2/mapPathfinding.py @@ -0,0 +1,558 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + """Return the four tiles occupied by a core whose position is top-left.""" + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Conveyors whose real output chain reaches the core. + + This deliberately does not use geometric proximity. A wrong-facing link, + dead end, or cycle remains disconnected even when it touches a valid trunk. + """ + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + output = position.add(facing) + if output in core_tiles or output in connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + """Adjacent conveyors which accept from, rather than output into, a mine.""" + roots = [] + for direction in CARDINALS: + position = harvester.add(direction) + facing = conveyor_directions.get(position) + if facing is not None and position.add(facing) != harvester: + roots.append(position) + return roots + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + """Follow facings from root; return (status, first missing output tile).""" + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", None + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + # Per-builder observations, refreshed whenever a tile re-enters vision. + # They make facing-based connectivity possible without consuming store + # slots or encoding map/opponent fingerprints. + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh economy-graph memory for one currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + """Optimistic number of new links to the core/known valid trunk.""" + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def economyRouteCandidates(self, ct: Controller): + """Visible missing links belonging to an observed friendly harvester. + + Defensive conveyors never become work merely because they have an empty + output. Every returned dead end is traced from a real harvester root. + """ + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status == "dead_end" and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if (ct.get_team(endId) != ct.get_team() + or ct.get_entity_type(endId) + != EntityType.BARRIER): + continue + if not ct.is_tile_passable(end): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position, connected): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + # A remembered conveyor is traversable as a merge target only when its + # actual facing chain is proven to reach the core. This is the central + # distinction the parent router omitted. + if tile in self.knownTeamConveyors: + if tile not in connected: + return [None, 'stuck'] + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) + != EntityType.CONVEYOR): + return [None, 'stuck'] + if ct.get_stored_resource(tileId) is not None: + return [None, 'stuck'] + return [0, 'done'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + return [None, 'stuck'] # unproven or opposing graph state + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + # Valid empty trunks are optional merge targets. Loaded trunks are + # temporarily excluded because a conveyor can hold only one stack. + for position in connected: + if not (0 <= position.x < w and 0 <= position.y < h): + continue + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) + != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + mergeCost = 8 + if mergeCost < self.conveyorMap[position.x][position.y][0]: + self.conveyorMap[position.x][position.y] = [mergeCost, 'done'] + buckets[mergeCost].append(position) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost( + ct, nextTile, connected) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_connected_economy_trace2/symmetry.py b/bots/exp_connected_economy_trace2/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_connected_economy_trace2/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_core_sniper/initialSpawning.py b/bots/exp_core_sniper/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_core_sniper/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_core_sniper/main.py b/bots/exp_core_sniper/main.py new file mode 100644 index 0000000..7f6db54 --- /dev/null +++ b/bots/exp_core_sniper/main.py @@ -0,0 +1,702 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Walk to an exact gunner firing seat and rebuild it indefinitely.""" + target = self._enemy_core_target() + if target is None: + return + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + best = None + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + + if best is None: + self.mapPf.moveTo(ct, target) + return + + _, seat, stand, facing = best + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_core_sniper/mapPathfinding.py b/bots/exp_core_sniper/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_core_sniper/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_core_sniper/symmetry.py b/bots/exp_core_sniper/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_core_sniper/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_emergency_countertrade/initialSpawning.py b/bots/exp_emergency_countertrade/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_emergency_countertrade/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_emergency_countertrade/main.py b/bots/exp_emergency_countertrade/main.py new file mode 100644 index 0000000..29d6af8 --- /dev/null +++ b/bots/exp_emergency_countertrade/main.py @@ -0,0 +1,1175 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_emergency_countertrade/mapPathfinding.py b/bots/exp_emergency_countertrade/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_emergency_countertrade/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_emergency_countertrade/symmetry.py b/bots/exp_emergency_countertrade/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_emergency_countertrade/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_emergency_countertrade_trace/initialSpawning.py b/bots/exp_emergency_countertrade_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_emergency_countertrade_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_emergency_countertrade_trace/main.py b/bots/exp_emergency_countertrade_trace/main.py new file mode 100644 index 0000000..bb55442 --- /dev/null +++ b/bots/exp_emergency_countertrade_trace/main.py @@ -0,0 +1,1208 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HOME_COUNTER_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/v89-home-counter-traces/emergency_trace.log" +) + + +def home_counter_trace(*parts): + with open(HOME_COUNTER_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + "\n") + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if bounded_trade: + home_counter_trace( + "TRADE_PLAN", + ct.get_current_round(), + ct.get_id(), + len(turret_attacks), + ct.get_global_resources(), + myLoc.x, + myLoc.y, + plan[0].x, + plan[0].y, + plan[1].x, + plan[1].y, + ) + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + if bounded_trade: + home_counter_trace( + "TRADE_BUILD", + ct.get_current_round(), + ct.get_id(), + threat.x, + threat.y, + seat.x, + seat.y, + ) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_emergency_countertrade_trace/mapPathfinding.py b/bots/exp_emergency_countertrade_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_emergency_countertrade_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_emergency_countertrade_trace/symmetry.py b/bots/exp_emergency_countertrade_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_emergency_countertrade_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_generalist_stack/initialSpawning.py b/bots/exp_generalist_stack/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_generalist_stack/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_generalist_stack/main.py b/bots/exp_generalist_stack/main.py new file mode 100644 index 0000000..dd6d6f5 --- /dev/null +++ b/bots/exp_generalist_stack/main.py @@ -0,0 +1,1093 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_generalist_stack/mapPathfinding.py b/bots/exp_generalist_stack/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_generalist_stack/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_generalist_stack/symmetry.py b/bots/exp_generalist_stack/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_generalist_stack/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_intercept/initialSpawning.py b/bots/exp_healer_intercept/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_intercept/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_intercept/main.py b/bots/exp_healer_intercept/main.py new file mode 100644 index 0000000..65eb8b4 --- /dev/null +++ b/bots/exp_healer_intercept/main.py @@ -0,0 +1,1247 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_PURSUIT_ROUNDS = 10 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerTargetId = None + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + current_round = ct.get_current_round() + active_target = next( + (item for item in healers if item[0] == self._healerTargetId), + None, + ) + target_expired = ( + self._healerTargetId is not None + and ( + not has_direct_gunner + or active_target is None + or current_round > self._healerTargetUntil + ) + ) + if target_expired: + self._healerTargetId = None + active_target = None + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + current_round + HEALER_SUPPRESSION_COOLDOWN + ) + if active_target is not None: + self._enemyCoreObservedHp = current_hp + return active_target[1] + + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (current_round < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + self._healerTargetId = healers[0][0] + self._healerTargetUntil = current_round + HEALER_PURSUIT_ROUNDS + return healers[0][1] + + def _healer_intercept_plans( + self, ct: Controller, plans, healer_target: Position, unsafe): + intercepts = [] + for plan in plans: + _, seat, stand, facing = plan + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, healer_target): + continue + intercepts.append(( + 0 if seat not in unsafe and stand not in unsafe else 1, + plan, + )) + intercepts.sort(key=lambda item: (item[0], item[1][0])) + return [item[1] for item in intercepts] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + intercept_plans = self._healer_intercept_plans( + ct, plans, healer_target, unsafe) + if intercept_plans: + if self._execute_siege_plan( + ct, myLoc, intercept_plans[0][1:]): + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + return + self._healerTargetId = None + self._healerTargetUntil = 0 + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + ct.get_current_round() + HEALER_SUPPRESSION_COOLDOWN + ) + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_intercept/mapPathfinding.py b/bots/exp_healer_intercept/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_intercept/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_intercept/symmetry.py b/bots/exp_healer_intercept/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_intercept/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_intercept_trace/initialSpawning.py b/bots/exp_healer_intercept_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_intercept_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_intercept_trace/main.py b/bots/exp_healer_intercept_trace/main.py new file mode 100644 index 0000000..eaf2c38 --- /dev/null +++ b/bots/exp_healer_intercept_trace/main.py @@ -0,0 +1,1271 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_PURSUIT_ROUNDS = 10 +HEALER_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/v89-healer-traces/intercept_trace.log" +) + + +def healer_trace(*parts): + with open(HEALER_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + "\n") + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerTargetId = None + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + current_round = ct.get_current_round() + active_target = next( + (item for item in healers if item[0] == self._healerTargetId), + None, + ) + target_expired = ( + self._healerTargetId is not None + and ( + not has_direct_gunner + or active_target is None + or current_round > self._healerTargetUntil + ) + ) + if target_expired: + self._healerTargetId = None + active_target = None + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + current_round + HEALER_SUPPRESSION_COOLDOWN + ) + if active_target is not None: + self._enemyCoreObservedHp = current_hp + return active_target[1] + + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (current_round < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + self._healerTargetId = healers[0][0] + self._healerTargetUntil = current_round + HEALER_PURSUIT_ROUNDS + healer_trace( + "HEALER_TARGET", + current_round, + ct.get_id(), + current_hp, + healers[0][1].x, + healers[0][1].y, + ) + return healers[0][1] + + def _healer_intercept_plans( + self, ct: Controller, plans, healer_target: Position, unsafe): + intercepts = [] + for plan in plans: + _, seat, stand, facing = plan + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, healer_target): + continue + intercepts.append(( + 0 if seat not in unsafe and stand not in unsafe else 1, + plan, + )) + intercepts.sort(key=lambda item: (item[0], item[1][0])) + return [item[1] for item in intercepts] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + intercept_plans = self._healer_intercept_plans( + ct, plans, healer_target, unsafe) + if intercept_plans: + if self._execute_siege_plan( + ct, myLoc, intercept_plans[0][1:]): + healer_trace( + "HEALER_BUILD", + ct.get_current_round(), + ct.get_id(), + healer_target.x, + healer_target.y, + ) + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + return + self._healerTargetId = None + self._healerTargetUntil = 0 + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + ct.get_current_round() + HEALER_SUPPRESSION_COOLDOWN + ) + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_intercept_trace/mapPathfinding.py b/bots/exp_healer_intercept_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_intercept_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_intercept_trace/symmetry.py b/bots/exp_healer_intercept_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_intercept_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_risk_parity/initialSpawning.py b/bots/exp_healer_risk_parity/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_risk_parity/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_risk_parity/main.py b/bots/exp_healer_risk_parity/main.py new file mode 100644 index 0000000..8cfd2e3 --- /dev/null +++ b/bots/exp_healer_risk_parity/main.py @@ -0,0 +1,1273 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_PURSUIT_ROUNDS = 10 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerTargetId = None + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + current_round = ct.get_current_round() + active_target = next( + (item for item in healers if item[0] == self._healerTargetId), + None, + ) + target_expired = ( + self._healerTargetId is not None + and ( + not has_direct_gunner + or active_target is None + or current_round > self._healerTargetUntil + ) + ) + if target_expired: + self._healerTargetId = None + active_target = None + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + current_round + HEALER_SUPPRESSION_COOLDOWN + ) + if active_target is not None: + self._enemyCoreObservedHp = current_hp + return active_target[1] + + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (current_round < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + self._healerTargetId = healers[0][0] + self._healerTargetUntil = current_round + HEALER_PURSUIT_ROUNDS + return healers[0][1] + + def _risk_parity_healer_plan( + self, ct: Controller, myLoc: Position, + healer_target: Position, unsafe, direct_plans): + if not direct_plans: + return None + direct_exposure = min( + int(seat in unsafe) + int(stand in unsafe) + for _, seat, stand, _ in direct_plans + ) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, healer_target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + exposure = ( + int(seat in unsafe) + int(stand in unsafe) + ) + key = ( + exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(healer_target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None or best[0][0] > direct_exposure: + return None + return best[1:] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + suppression_plan = self._risk_parity_healer_plan( + ct, myLoc, healer_target, unsafe, plans) + if suppression_plan is not None: + if self._execute_siege_plan( + ct, myLoc, suppression_plan): + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + return + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_risk_parity/mapPathfinding.py b/bots/exp_healer_risk_parity/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_risk_parity/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_risk_parity/symmetry.py b/bots/exp_healer_risk_parity/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_risk_parity/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_risk_parity_trace/initialSpawning.py b/bots/exp_healer_risk_parity_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_risk_parity_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_risk_parity_trace/main.py b/bots/exp_healer_risk_parity_trace/main.py new file mode 100644 index 0000000..ed4a4fd --- /dev/null +++ b/bots/exp_healer_risk_parity_trace/main.py @@ -0,0 +1,1297 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_PURSUIT_ROUNDS = 10 +HEALER_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/v89-healer-traces/risk_parity_trace.log" +) + + +def healer_trace(*parts): + with open(HEALER_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + "\n") + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerTargetId = None + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + current_round = ct.get_current_round() + active_target = next( + (item for item in healers if item[0] == self._healerTargetId), + None, + ) + target_expired = ( + self._healerTargetId is not None + and ( + not has_direct_gunner + or active_target is None + or current_round > self._healerTargetUntil + ) + ) + if target_expired: + self._healerTargetId = None + active_target = None + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + current_round + HEALER_SUPPRESSION_COOLDOWN + ) + if active_target is not None: + self._enemyCoreObservedHp = current_hp + return active_target[1] + + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (current_round < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + self._healerTargetId = healers[0][0] + self._healerTargetUntil = current_round + HEALER_PURSUIT_ROUNDS + healer_trace( + "HEALER_TARGET", + current_round, + ct.get_id(), + current_hp, + healers[0][1].x, + healers[0][1].y, + ) + return healers[0][1] + + def _risk_parity_healer_plan( + self, ct: Controller, myLoc: Position, + healer_target: Position, unsafe, direct_plans): + if not direct_plans: + return None + direct_exposure = min( + int(seat in unsafe) + int(stand in unsafe) + for _, seat, stand, _ in direct_plans + ) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, healer_target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + exposure = ( + int(seat in unsafe) + int(stand in unsafe) + ) + key = ( + exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(healer_target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None or best[0][0] > direct_exposure: + return None + return best[1:] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + suppression_plan = self._risk_parity_healer_plan( + ct, myLoc, healer_target, unsafe, plans) + if suppression_plan is not None: + if self._execute_siege_plan( + ct, myLoc, suppression_plan): + healer_trace( + "HEALER_BUILD", + ct.get_current_round(), + ct.get_id(), + healer_target.x, + healer_target.y, + ) + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + return + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_risk_parity_trace/mapPathfinding.py b/bots/exp_healer_risk_parity_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_risk_parity_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_risk_parity_trace/symmetry.py b/bots/exp_healer_risk_parity_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_risk_parity_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_suppression/initialSpawning.py b/bots/exp_healer_suppression/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_suppression/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_suppression/main.py b/bots/exp_healer_suppression/main.py new file mode 100644 index 0000000..e6871af --- /dev/null +++ b/bots/exp_healer_suppression/main.py @@ -0,0 +1,1194 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (ct.get_current_round() < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + return healers[0][1] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + suppression_plan = self._safe_counter_plan( + ct, myLoc, healer_target, unsafe) + if suppression_plan is not None: + if self._execute_siege_plan( + ct, myLoc, suppression_plan): + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + return + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_suppression/mapPathfinding.py b/bots/exp_healer_suppression/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_suppression/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_suppression/symmetry.py b/bots/exp_healer_suppression/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_suppression/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_suppression_sticky/initialSpawning.py b/bots/exp_healer_suppression_sticky/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_suppression_sticky/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_suppression_sticky/main.py b/bots/exp_healer_suppression_sticky/main.py new file mode 100644 index 0000000..aa241fe --- /dev/null +++ b/bots/exp_healer_suppression_sticky/main.py @@ -0,0 +1,1226 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_PURSUIT_ROUNDS = 10 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerTargetId = None + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + current_round = ct.get_current_round() + active_target = next( + (item for item in healers if item[0] == self._healerTargetId), + None, + ) + target_expired = ( + self._healerTargetId is not None + and ( + not has_direct_gunner + or active_target is None + or current_round > self._healerTargetUntil + ) + ) + if target_expired: + self._healerTargetId = None + active_target = None + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + current_round + HEALER_SUPPRESSION_COOLDOWN + ) + if active_target is not None: + self._enemyCoreObservedHp = current_hp + return active_target[1] + + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (current_round < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + self._healerTargetId = healers[0][0] + self._healerTargetUntil = current_round + HEALER_PURSUIT_ROUNDS + return healers[0][1] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + suppression_plan = self._safe_counter_plan( + ct, myLoc, healer_target, unsafe) + if suppression_plan is not None: + if self._execute_siege_plan( + ct, myLoc, suppression_plan): + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + return + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_suppression_sticky/mapPathfinding.py b/bots/exp_healer_suppression_sticky/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_suppression_sticky/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_suppression_sticky/symmetry.py b/bots/exp_healer_suppression_sticky/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_suppression_sticky/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_suppression_sticky_trace/initialSpawning.py b/bots/exp_healer_suppression_sticky_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_suppression_sticky_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_suppression_sticky_trace/main.py b/bots/exp_healer_suppression_sticky_trace/main.py new file mode 100644 index 0000000..39c736f --- /dev/null +++ b/bots/exp_healer_suppression_sticky_trace/main.py @@ -0,0 +1,1250 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_PURSUIT_ROUNDS = 10 +HEALER_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/v89-healer-traces/sticky_trace.log" +) + + +def healer_trace(*parts): + with open(HEALER_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + "\n") + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerTargetId = None + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + current_round = ct.get_current_round() + active_target = next( + (item for item in healers if item[0] == self._healerTargetId), + None, + ) + target_expired = ( + self._healerTargetId is not None + and ( + not has_direct_gunner + or active_target is None + or current_round > self._healerTargetUntil + ) + ) + if target_expired: + self._healerTargetId = None + active_target = None + self._healerStallCount = 0 + self._healerSuppressionUntil = ( + current_round + HEALER_SUPPRESSION_COOLDOWN + ) + if active_target is not None: + self._enemyCoreObservedHp = current_hp + return active_target[1] + + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (current_round < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + self._healerTargetId = healers[0][0] + self._healerTargetUntil = current_round + HEALER_PURSUIT_ROUNDS + healer_trace( + "HEALER_TARGET", + current_round, + ct.get_id(), + current_hp, + healers[0][1].x, + healers[0][1].y, + ) + return healers[0][1] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + suppression_plan = self._safe_counter_plan( + ct, myLoc, healer_target, unsafe) + if suppression_plan is not None: + if self._execute_siege_plan( + ct, myLoc, suppression_plan): + healer_trace( + "HEALER_BUILD", + ct.get_current_round(), + ct.get_id(), + healer_target.x, + healer_target.y, + ) + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + self._healerTargetId = None + self._healerTargetUntil = 0 + return + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_suppression_sticky_trace/mapPathfinding.py b/bots/exp_healer_suppression_sticky_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_suppression_sticky_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_suppression_sticky_trace/symmetry.py b/bots/exp_healer_suppression_sticky_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_suppression_sticky_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_healer_suppression_trace/initialSpawning.py b/bots/exp_healer_suppression_trace/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_healer_suppression_trace/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_healer_suppression_trace/main.py b/bots/exp_healer_suppression_trace/main.py new file mode 100644 index 0000000..8900922 --- /dev/null +++ b/bots/exp_healer_suppression_trace/main.py @@ -0,0 +1,1218 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 +HEALER_STALL_ROUNDS = 6 +HEALER_SUPPRESSION_COOLDOWN = 12 +HEALER_TRACE_PATH = ( + "/mnt/c/Users/subodh/Downloads/" + "fcode-gate-artifacts/v89-healer-traces/healer_trace.log" +) + + +def healer_trace(*parts): + with open(HEALER_TRACE_PATH, "a", encoding="utf-8") as stream: + stream.write(" ".join(str(part) for part in parts) + "\n") + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def next_healer_stall_count( + previous_hp, current_hp, has_direct_gunner, + healer_count, current_count): + """Count consecutive observations where visible healing stops HP progress.""" + if not has_direct_gunner or healer_count == 0: + return 0 + if previous_hp is None or current_hp >= previous_hp: + return current_count + 1 + return 0 + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + self._healerSuppressionUntil = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _friendly_direct_gunner_exists( + self, ct: Controller, target: Position) -> bool: + target_tiles = set(core_footprint(target)) + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + attack_tiles = ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER, + ) + if target_tiles.intersection(attack_tiles): + return True + return False + + def _healer_suppression_target( + self, ct: Controller, myLoc: Position, target: Position): + core_id = None + my_team = ct.get_team() + for core_tile in core_footprint(target): + if not ct.is_in_vision(core_tile): + continue + building_id = ct.get_tile_building_id(core_tile) + if (building_id is not None + and ct.get_team(building_id) != my_team + and ct.get_entity_type(building_id) + == EntityType.CORE): + core_id = building_id + break + if core_id is None: + self._enemyCoreObservedHp = None + self._healerStallCount = 0 + return None + + healers = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) + != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, target): + healers.append((unit_id, position)) + + current_hp = ct.get_hp(core_id) + has_direct_gunner = self._friendly_direct_gunner_exists(ct, target) + self._healerStallCount = next_healer_stall_count( + self._enemyCoreObservedHp, + current_hp, + has_direct_gunner, + len(healers), + self._healerStallCount, + ) + self._enemyCoreObservedHp = current_hp + if (ct.get_current_round() < self._healerSuppressionUntil + or self._healerStallCount < HEALER_STALL_ROUNDS + or not healers): + return None + healers.sort(key=lambda item: ( + myLoc.distance_squared(item[1]), + item[0], + )) + healer_trace( + "HEALER_TARGET", + ct.get_current_round(), + ct.get_id(), + current_hp, + healers[0][1].x, + healers[0][1].y, + ) + return healers[0][1] + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + + healer_target = self._healer_suppression_target( + ct, myLoc, target) + if healer_target is not None: + suppression_plan = self._safe_counter_plan( + ct, myLoc, healer_target, unsafe) + if suppression_plan is not None: + if self._execute_siege_plan( + ct, myLoc, suppression_plan): + healer_trace( + "HEALER_BUILD", + ct.get_current_round(), + ct.get_id(), + healer_target.x, + healer_target.y, + ) + self._healerSuppressionUntil = ( + ct.get_current_round() + + HEALER_SUPPRESSION_COOLDOWN + ) + self._healerStallCount = 0 + return + + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> bool: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return False + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + return True + return False + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_healer_suppression_trace/mapPathfinding.py b/bots/exp_healer_suppression_trace/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_healer_suppression_trace/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_healer_suppression_trace/symmetry.py b/bots/exp_healer_suppression_trace/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_healer_suppression_trace/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_late_route_repair/README.md b/bots/exp_late_route_repair/README.md new file mode 100644 index 0000000..523d559 --- /dev/null +++ b/bots/exp_late_route_repair/README.md @@ -0,0 +1,26 @@ +# Late Route Repair experiment + +Parent: frozen `meta-generalist-v1` (reported live on ladder). + +This is the narrowed successor to rejected `exp_connected_economy`, whose +broad opening-router rewrite failed its 2.3.3 crash screen at 3/12 with core +kills 3-9. + +The parent opening, route scoring, conveyor pathfinder, round-40 transition, +siege, defense, spawning, and store protocol remain unchanged through round 79. +Each builder only observes friendly harvester positions and conveyor facings in +its existing local cache. + +From round 80 onward, if a built harvester's real facing chain does not reach +the core, one locally nearest non-sieger/non-responder may finish at most an +eight-link route while the bank has at least 100 titanium. The repair uses at +most the parent's original route score of six, so nearby direct threats retain +their score-ten priority. It may merge only into a facing-proven, empty trunk; +wrong-facing links, cycles, dead ends, loaded trunks, and defensive conveyors +that do not reach the core are rejected as merge targets. A cycle rooted at an +orphan harvester is repaired by removing one disconnected loop link and +rebuilding it toward a proven trunk. A friendly builder standing on the missing +link owns it and can build the walkable conveyor under itself. + +No map name, dimensions, side, opponent identity, or new store slot is used. +Root sources, the frozen parent, and the live package are untouched. diff --git a/bots/exp_late_route_repair/initialSpawning.py b/bots/exp_late_route_repair/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_late_route_repair/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_late_route_repair/main.py b/bots/exp_late_route_repair/main.py new file mode 100644 index 0000000..8edea74 --- /dev/null +++ b/bots/exp_late_route_repair/main.py @@ -0,0 +1,1332 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 80 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer(candidates, target: Position, excluded_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: (item[1].distance_squared(target), item[0]), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + return choose_route_repairer( + candidates, target, excluded) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's exact transition. Only after round 80 may one + # nearest non-specialist finish a short route to an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_late_route_repair/mapPathfinding.py b/bots/exp_late_route_repair/mapPathfinding.py new file mode 100644 index 0000000..0c192e6 --- /dev/null +++ b/bots/exp_late_route_repair/mapPathfinding.py @@ -0,0 +1,643 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_late_route_repair/symmetry.py b/bots/exp_late_route_repair/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_late_route_repair/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_local_responder/initialSpawning.py b/bots/exp_local_responder/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_local_responder/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_local_responder/main.py b/bots/exp_local_responder/main.py new file mode 100644 index 0000000..5c26554 --- /dev/null +++ b/bots/exp_local_responder/main.py @@ -0,0 +1,945 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Walk to an exact gunner firing seat and rebuild it indefinitely.""" + target = self._enemy_core_target() + if target is None: + return + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + best = None + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + + if best is None: + self.mapPf.moveTo(ct, target) + return + + _, seat, stand, facing = best + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_local_responder/mapPathfinding.py b/bots/exp_local_responder/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_local_responder/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_local_responder/symmetry.py b/bots/exp_local_responder/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_local_responder/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_armor_rebuild/README.md b/bots/exp_meta_armor_rebuild/README.md new file mode 100644 index 0000000..c1920bc --- /dev/null +++ b/bots/exp_meta_armor_rebuild/README.md @@ -0,0 +1,10 @@ +# Bounded armor maintenance + +Parent: `exp_prearmor`, the six-site variant that was neutral over 336 lineage +games and 56% over two 168-game rush runs. + +The opening ring, offense, economy, spawning, recall, responder, and siege are +unchanged. The waller remembers only the same six claimed firing-seat sites. +After a verified core threat is published, it heals damaged barriers and +replaces destroyed ones; after turn 60 it never expands to a new site. This is +the replay-observed rebuild loop without a permanent guard or larger ring. diff --git a/bots/exp_meta_armor_rebuild/initialSpawning.py b/bots/exp_meta_armor_rebuild/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_armor_rebuild/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_armor_rebuild/main.py b/bots/exp_meta_armor_rebuild/main.py new file mode 100644 index 0000000..2c4960e --- /dev/null +++ b/bots/exp_meta_armor_rebuild/main.py @@ -0,0 +1,1365 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# PRE-ARMOUR, measured from The Flotte Experience (#5), who beat us 22-3: +# 100% of their 28 barriers across 5 games sat within 4 tiles of their OWN core, +# laid from t4 by a dedicated builder, before any threat existed. In the game I +# traced we built ELEVEN gunners starting t13 to their eight from t18 and still +# lost 5-0 - our guns need seats at range 4-6 from their core and those tiles +# were occupied. +# Our waller already walls manhattan 1 with conveyors, which blocks adjacency but +# not the FIRING SEATS. This adds the seat zone. 3 Ti for 30 HP is the cheapest +# denial in the game, and per the team's own note our barriers are passable to +# our own units, so it costs us no movement. +# This is NOT aegis-v1 or bastion: both were REACTIVE armour built after a +# threat appeared. This is pre-placed, which is the whole point. +ARMOR_MAX = 6 +ARMOR_MIN_D = 2 +ARMOR_MAX_D = 4 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def armor_maintenance_action( + threatened: bool, claimed: bool, building_is_friendly_barrier: bool, + damaged: bool, site_empty: bool): + """Pure maintenance policy for a previously claimed armor site.""" + if not threatened or not claimed: + return None + if building_is_friendly_barrier: + return "heal" if damaged else None + return "rebuild" if site_empty else None + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._armorSites = set() + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and ct.get_current_round() > 5 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self._pre_armor( + ct, myLoc, allow_new=ct.get_current_round() <= 60): + return + if (ct.get_current_round() <= 60 + and self.wallDuty(ct, myLoc)): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _pre_armor( + self, ct: Controller, myLoc: Position, allow_new: bool = True) -> bool: + """Build six firing-seat barriers, then repair them only under threat.""" + tc = self.mapPf.teamCore + eg = self._enemy_core_target() + if tc is None or eg is None: + return False + core_tiles = [Position(tc.x + dx, tc.y + dy) + for dx in (0, 1) for dy in (0, 1)] + eligible = [] + for x in range(tc.x - ARMOR_MAX_D, tc.x + ARMOR_MAX_D + 2): + for y in range(tc.y - ARMOR_MAX_D, tc.y + ARMOR_MAX_D + 2): + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + seat = Position(x, y) + d = abs(x - tc.x) + abs(y - tc.y) + if not (ARMOR_MIN_D <= d <= ARMOR_MAX_D): + continue + # enemy side only - the home ring is the core's heal access + if abs(x - eg.x) + abs(y - eg.y) > abs(tc.x - eg.x) + abs(tc.y - eg.y): + continue + if not ct.is_in_vision(seat): + continue + # only tiles a gunner could actually shoot our core from + shoots = False + for facing in DIRECTIONS: + for ct_tile in core_tiles: + if ct.can_fire_from(seat, facing, EntityType.GUNNER, ct_tile): + shoots = True + break + if shoots: + break + if not shoots: + continue + eligible.append((abs(myLoc.x - x) + abs(myLoc.y - y), seat)) + + threatened = ct.read_store(SLOT_HOME_THREAT) != 0 + if threatened: + maintenance = [] + for distance, site in eligible: + if site not in self._armorSites: + continue + building_id = ct.get_tile_building_id(site) + friendly_barrier = ( + building_id is not None + and ct.get_team(building_id) == ct.get_team() + and ct.get_entity_type(building_id) == EntityType.BARRIER + ) + damaged = ( + friendly_barrier + and ct.get_hp(building_id) < ct.get_max_hp(building_id) + ) + action = armor_maintenance_action( + True, True, friendly_barrier, damaged, + building_id is None) + if action is not None: + maintenance.append((0 if action == "heal" else 1, + distance, site, action)) + if maintenance: + _, distance, site, action = min(maintenance) + if action == "heal" and ct.can_act() and ct.can_heal(site): + ct.heal(site) + return True + if (action == "rebuild" and ct.can_act() + and ct.can_build_barrier(site)): + ct.build_barrier(site) + return True + if distance > 1: + self.mapPf.moveTo(ct, site) + return True + + if not allow_new or len(self._armorSites) >= ARMOR_MAX: + return False + choices = [ + (distance, site) for distance, site in eligible + if site not in self._armorSites + and ct.get_tile_building_id(site) is None + ] + if not choices: + return False + distance, seat = min(choices) + if ct.can_act() and ct.can_build_barrier(seat): + ct.build_barrier(seat) + self._armorSites.add(seat) + return True + if distance > 1: + self.mapPf.moveTo(ct, seat) + return True + return False + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_armor_rebuild/mapPathfinding.py b/bots/exp_meta_armor_rebuild/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_armor_rebuild/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_armor_rebuild/symmetry.py b/bots/exp_meta_armor_rebuild/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_armor_rebuild/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_breacher/README.md b/bots/exp_meta_breacher/README.md new file mode 100644 index 0000000..7e5ade8 --- /dev/null +++ b/bots/exp_meta_breacher/README.md @@ -0,0 +1,10 @@ +# Surgical firing-seat breacher + +Parent: frozen `exp_trans_40`. + +When direct core plans exist, behavior is identical to v8. Only when no direct +plan exists may Sieger 2 identify a visible enemy barrier that itself occupies a +legal core-firing seat and place a safe gunner against it. It does not target +generic walls or economy buildings, does not suspend Sieger 1, and immediately +returns to direct siege when the seat opens. Existing turret counter-battery, +recall, economy, spawning, and pathfinding are unchanged. diff --git a/bots/exp_meta_breacher/initialSpawning.py b/bots/exp_meta_breacher/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_breacher/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_breacher/main.py b/bots/exp_meta_breacher/main.py new file mode 100644 index 0000000..1954f97 --- /dev/null +++ b/bots/exp_meta_breacher/main.py @@ -0,0 +1,1295 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + # A fortified core can occupy every legal firing seat with 3-Ti + # barriers. With no direct plan, v8 walks at the core forever. Sieger 2 + # may open exactly one visible seat while Sieger 1 keeps direct pressure. + if not plans: + blocker = self._visible_core_seat_blocker(ct, target) + if blocker is not None: + if self._friendly_gunner_covers(ct, blocker): + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, + pack_position(blocker)) + self.mapPf.moveTo(ct, target) + return + breach_plan = self._safe_counter_plan( + ct, myLoc, blocker, unsafe) + if breach_plan is not None: + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, + pack_position(blocker)) + self._execute_siege_plan(ct, myLoc, breach_plan) + return + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _visible_core_seat_blocker( + self, ct: Controller, target: Position): + """Return a visible enemy barrier occupying a real direct-fire seat.""" + my_team = ct.get_team() + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + blockers = [] + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) == my_team + or ct.get_entity_type(building_id) != EntityType.BARRIER): + continue + position = ct.get_position(building_id) + is_firing_seat = any( + ct.can_fire_from( + position, facing, EntityType.GUNNER, core_tile) + for facing in DIRECTIONS + for core_tile in core_tiles + ) + if is_firing_seat: + blockers.append(( + position.distance_squared(target), + position.x, + position.y, + building_id, + position, + )) + return min(blockers)[4] if blockers else None + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_breacher/mapPathfinding.py b/bots/exp_meta_breacher/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_breacher/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_breacher/symmetry.py b/bots/exp_meta_breacher/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_breacher/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_builder_targeting/README.md b/bots/exp_meta_builder_targeting/README.md new file mode 100644 index 0000000..47e4ee6 --- /dev/null +++ b/bots/exp_meta_builder_targeting/README.md @@ -0,0 +1,11 @@ +# Gunner builder-target correctness fix + +Parent: frozen `exp_trans_40`. + +This changes only gunner target handling. The parent fired a current target only +when the tile contained an enemy building, and its enemy-builder rotation score +was accidentally nested inside the same building condition. Enemy builders on +otherwise empty tiles were therefore ignored. This variant fires a visible +enemy builder and scores that separate unit layer at the existing weight of 2. + +No strategy thresholds, economy, siege, spawning, stores, or map logic change. diff --git a/bots/exp_meta_builder_targeting/initialSpawning.py b/bots/exp_meta_builder_targeting/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_builder_targeting/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_builder_targeting/main.py b/bots/exp_meta_builder_targeting/main.py new file mode 100644 index 0000000..e625940 --- /dev/null +++ b/bots/exp_meta_builder_targeting/main.py @@ -0,0 +1,1252 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_builder_targeting/mapPathfinding.py b/bots/exp_meta_builder_targeting/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_builder_targeting/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_builder_targeting/symmetry.py b/bots/exp_meta_builder_targeting/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_builder_targeting/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_emergency_ammo/README.md b/bots/exp_meta_emergency_ammo/README.md new file mode 100644 index 0000000..4c38776 --- /dev/null +++ b/bots/exp_meta_emergency_ammo/README.md @@ -0,0 +1,6 @@ +# Emergency ammo reserve + +Parent: frozen `exp_trans_40`. This independently tests only v6's omitted +low-HP ammo behavior: during a verified core attack, refill toward 20 ammo while +preserving 28 titanium. Gunner aiming, builders, siege, economy, spawning, and +all other behavior are unchanged. diff --git a/bots/exp_meta_emergency_ammo/initialSpawning.py b/bots/exp_meta_emergency_ammo/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_emergency_ammo/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_emergency_ammo/main.py b/bots/exp_meta_emergency_ammo/main.py new file mode 100644 index 0000000..def4284 --- /dev/null +++ b/bots/exp_meta_emergency_ammo/main.py @@ -0,0 +1,1261 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +EMERGENCY_AMMO_TARGET = 20 +EMERGENCY_TITANIUM_RESERVE = 28 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def emergency_ammo_conversion( + global_ammo: int, global_titanium: int, under_attack: bool) -> int: + if not under_attack: + return 0 + return max(0, min( + EMERGENCY_AMMO_TARGET - global_ammo, + global_titanium - EMERGENCY_TITANIUM_RESERVE, + )) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + emergency_conversion = emergency_ammo_conversion( + globalAmmo, globalTitanium, under_attack) + if emergency_conversion > 0: + if ct.can_convert_ammo(emergency_conversion): + ct.convert_ammo(emergency_conversion) + elif globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_emergency_ammo/mapPathfinding.py b/bots/exp_meta_emergency_ammo/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_emergency_ammo/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_emergency_ammo/symmetry.py b/bots/exp_meta_emergency_ammo/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_emergency_ammo/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_gunner_aim/README.md b/bots/exp_meta_gunner_aim/README.md new file mode 100644 index 0000000..dd1b6c3 --- /dev/null +++ b/bots/exp_meta_gunner_aim/README.md @@ -0,0 +1,6 @@ +# Reactive home-gunner aiming + +Parent: frozen `exp_trans_40`. This independently tests only v6's omitted +home-gunner steering: slot 15 publishes the exact verified core threat, and a +friendly gunner that can hit it may rotate at a 25-Ti floor. Ammo conversion, +builders, siege, economy, spawning, and generic gunner scoring are unchanged. diff --git a/bots/exp_meta_gunner_aim/initialSpawning.py b/bots/exp_meta_gunner_aim/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_gunner_aim/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_gunner_aim/main.py b/bots/exp_meta_gunner_aim/main.py new file mode 100644 index 0000000..268945f --- /dev/null +++ b/bots/exp_meta_gunner_aim/main.py @@ -0,0 +1,1260 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +HOME_GUNNER_ROTATE_FLOOR = 25 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + home_threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if home_threat is not None: + threat_direction = None + for direction in DIRECTIONS: + if ct.can_fire_from( + myPos, direction, EntityType.GUNNER, home_threat): + threat_direction = direction + if direction == myDir: + break + if (threat_direction is not None + and threat_direction != myDir + and ct.get_global_resources() > HOME_GUNNER_ROTATE_FLOOR + and ct.can_rotate(threat_direction)): + ct.rotate(threat_direction) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_gunner_aim/mapPathfinding.py b/bots/exp_meta_gunner_aim/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_gunner_aim/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_gunner_aim/symmetry.py b/bots/exp_meta_gunner_aim/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_gunner_aim/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_gunner_defense/README.md b/bots/exp_meta_gunner_defense/README.md new file mode 100644 index 0000000..7c9eb15 --- /dev/null +++ b/bots/exp_meta_gunner_defense/README.md @@ -0,0 +1,12 @@ +# Reactive home-gunner experiment + +Parent: frozen `exp_trans_40` (production v8 lineage). + +This isolates the two defensive mechanics present in the old v6 live success +but omitted when recall was ported: nearby friendly gunners rotate directly onto +the exact core-hitting threat published in slot 15, and the core may refill ammo +during a real low-HP attack while preserving 28 titanium. Generic gunner logic, +recall, the responder, siege, economy, spawning, and pathfinding are unchanged. + +The behavior is memoryless and fully reactive. It activates only from the real +attack pattern used by the existing home-threat detector. diff --git a/bots/exp_meta_gunner_defense/initialSpawning.py b/bots/exp_meta_gunner_defense/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_gunner_defense/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_gunner_defense/main.py b/bots/exp_meta_gunner_defense/main.py new file mode 100644 index 0000000..124cd4c --- /dev/null +++ b/bots/exp_meta_gunner_defense/main.py @@ -0,0 +1,1284 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +EMERGENCY_AMMO_TARGET = 20 +EMERGENCY_TITANIUM_RESERVE = 28 +HOME_GUNNER_ROTATE_FLOOR = 25 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def emergency_ammo_conversion( + global_ammo: int, global_titanium: int, under_attack: bool) -> int: + """Amount of titanium to convert while preserving one repair/build reserve.""" + if not under_attack: + return 0 + return max(0, min( + EMERGENCY_AMMO_TARGET - global_ammo, + global_titanium - EMERGENCY_TITANIUM_RESERVE, + )) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + emergency_conversion = emergency_ammo_conversion( + globalAmmo, globalTitanium, under_attack) + if emergency_conversion > 0: + if ct.can_convert_ammo(emergency_conversion): + ct.convert_ammo(emergency_conversion) + elif globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + # The threat channel also steers nearby friendly gunners. Publish + # it even when no builder is available to become the responder. + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + # v6's live anti-Pantheon controller was only partially ported into v8: + # recall came across, but home gunners lost their explicit response to + # the exact core-hitting turret. Reuse the responder's verified threat + # channel, and lower the rotation reserve only for a shot on that tile. + home_threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if home_threat is not None: + threat_direction = None + for direction in DIRECTIONS: + if ct.can_fire_from( + myPos, direction, EntityType.GUNNER, home_threat): + threat_direction = direction + if direction == myDir: + break + if (threat_direction is not None + and threat_direction != myDir + and ct.get_global_resources() > HOME_GUNNER_ROTATE_FLOOR + and ct.can_rotate(threat_direction)): + ct.rotate(threat_direction) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_gunner_defense/mapPathfinding.py b/bots/exp_meta_gunner_defense/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_gunner_defense/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_gunner_defense/symmetry.py b/bots/exp_meta_gunner_defense/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_gunner_defense/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_late_reinforce/README.md b/bots/exp_meta_late_reinforce/README.md new file mode 100644 index 0000000..9ecfffa --- /dev/null +++ b/bots/exp_meta_late_reinforce/README.md @@ -0,0 +1,13 @@ +# Late reinforcement experiment + +Parent: frozen `exp_trans_40` (production v8 lineage). + +This tests the measured top-meta production order without changing the opening: +the first two siegers still pressure immediately, all existing economy builders +stay in their normal lifecycle, and only builders born at or after the t40 +economy transition join direct siege. The original waller remains the home +anchor. Recall, home response, safe counter-battery, spawning, economy scoring, +and pathfinding are byte-for-byte inherited. + +Unlike `exp_meta_reinforce`, this does not recall established economy builders +or convert the whole population at once. diff --git a/bots/exp_meta_late_reinforce/initialSpawning.py b/bots/exp_meta_late_reinforce/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_late_reinforce/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_late_reinforce/main.py b/bots/exp_meta_late_reinforce/main.py new file mode 100644 index 0000000..8331709 --- /dev/null +++ b/bots/exp_meta_late_reinforce/main.py @@ -0,0 +1,1260 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def is_late_reinforcement( + spawn_round: int, is_waller: bool, is_designated_sieger: bool) -> bool: + """Late production becomes offense; the established economy stays intact.""" + return ( + spawn_round >= ECON_CUTOFF + and not is_waller + and not is_designated_sieger + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self.spawnRound = None + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + if self.spawnRound is None: + self.spawnRound = ct.get_current_round() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + designated_sieger = self._is_sieger(ct) + late_reinforcement = is_late_reinforcement( + self.spawnRound, self._is_waller(ct), designated_sieger) + if ((designated_sieger or late_reinforcement) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_late_reinforce/mapPathfinding.py b/bots/exp_meta_late_reinforce/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_late_reinforce/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_late_reinforce/symmetry.py b/bots/exp_meta_late_reinforce/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_late_reinforce/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_reinforce/README.md b/bots/exp_meta_reinforce/README.md new file mode 100644 index 0000000..0d31e0e --- /dev/null +++ b/bots/exp_meta_reinforce/README.md @@ -0,0 +1,14 @@ +# Meta reinforcement experiment + +Parent: frozen `exp_trans_40` (production v8 lineage). + +The two opening siegers, t40 economy cutoff, soft recall, home responder, +surgical counter-battery, spawning, and pathfinding are unchanged. The isolated +hypothesis is that ordinary builders freed by the economy transition should join +the existing direct-siege lifecycle instead of exploring. A builder may convert +before t40 only after round 18, after five builders have spawned, and after it +personally sees an enemy core or combat building. The original waller never +converts, preserving one home/economy anchor. + +The trigger uses only observable match state. It contains no opponent or map +fingerprints. diff --git a/bots/exp_meta_reinforce/initialSpawning.py b/bots/exp_meta_reinforce/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_reinforce/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_reinforce/main.py b/bots/exp_meta_reinforce/main.py new file mode 100644 index 0000000..baf8865 --- /dev/null +++ b/bots/exp_meta_reinforce/main.py @@ -0,0 +1,1292 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +# Ordinary builders may convert earlier only after the opening exists and they +# personally observe strategic contact. This is deliberately local evidence: +# no opponent, map-name, map-size, or spawn-order fingerprint is involved. +REINFORCE_CONTACT_ROUND = 18 +REINFORCE_MIN_BUILDERS = 5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def should_commit_reinforcement( + current_round: int, num_spawned: int, is_waller: bool, + already_committed: bool, strategic_contact: bool) -> bool: + """Return whether an ordinary builder should join direct siege.""" + if is_waller: + return False + if already_committed or current_round >= ECON_CUTOFF: + return True + return ( + current_round >= REINFORCE_CONTACT_ROUND + and num_spawned >= REINFORCE_MIN_BUILDERS + and strategic_contact + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self.reinforcementCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + designated_sieger = self._is_sieger(ct) + reinforcement = self._should_reinforce_siege(ct) + if ((designated_sieger or reinforcement) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _has_strategic_contact(self, ct: Controller) -> bool: + """Observe only enemies that justify converting economy to pressure.""" + my_team = ct.get_team() + for entity_id in ct.get_nearby_entities(): + if ct.get_team(entity_id) == my_team: + continue + if ct.get_entity_type(entity_id) in ( + EntityType.CORE, EntityType.GUNNER, + EntityType.SENTINEL, EntityType.LAUNCHER): + return True + return False + + def _should_reinforce_siege(self, ct: Controller) -> bool: + """Latch freed ordinary builders into the existing siege lifecycle.""" + commit = should_commit_reinforcement( + ct.get_current_round(), + self.numSpawned, + self._is_waller(ct), + self.reinforcementCommitted, + self._has_strategic_contact(ct), + ) + if commit: + self.reinforcementCommitted = True + return commit + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_reinforce/mapPathfinding.py b/bots/exp_meta_reinforce/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_reinforce/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_reinforce/symmetry.py b/bots/exp_meta_reinforce/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_reinforce/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_roles/initialSpawning.py b/bots/exp_roles/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_roles/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_roles/main.py b/bots/exp_roles/main.py new file mode 100644 index 0000000..4a536da --- /dev/null +++ b/bots/exp_roles/main.py @@ -0,0 +1,624 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_roles/mapPathfinding.py b/bots/exp_roles/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_roles/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_roles/symmetry.py b/bots/exp_roles/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_roles/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_sequential_responder/initialSpawning.py b/bots/exp_sequential_responder/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_sequential_responder/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_sequential_responder/main.py b/bots/exp_sequential_responder/main.py new file mode 100644 index 0000000..3e35055 --- /dev/null +++ b/bots/exp_sequential_responder/main.py @@ -0,0 +1,1109 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def choose_home_threat(threats, covered_positions): + """Select the first uncovered threat, retaining one when all are covered.""" + if not threats: + return None + for threat in threats: + if threat[4] not in covered_positions: + return threat[4] + return threats[0][4] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + covered_positions = { + threat[4] + for threat in threats + if self._friendly_gunner_covers(ct, threat[4]) + } + self._homeThreatPos = choose_home_threat( + threats, covered_positions) + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + if plan is None or ct.get_global_resources() <= SIEGE_TITANIUM_FLOOR: + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_sequential_responder/mapPathfinding.py b/bots/exp_sequential_responder/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_sequential_responder/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_sequential_responder/symmetry.py b/bots/exp_sequential_responder/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_sequential_responder/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_spawn_discipline/initialSpawning.py b/bots/exp_spawn_discipline/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_spawn_discipline/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_spawn_discipline/main.py b/bots/exp_spawn_discipline/main.py new file mode 100644 index 0000000..a55a540 --- /dev/null +++ b/bots/exp_spawn_discipline/main.py @@ -0,0 +1,719 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +CORE_BUILDER_CONGESTION_LIMIT = 6 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def extra_spawn_allowed(nearby_friendly_builders: int) -> bool: + return nearby_friendly_builders < CORE_BUILDER_CONGESTION_LIMIT + + +def count_nearby_friendly_builders(ct: Controller) -> int: + my_team = ct.get_team() + return sum( + 1 for unit_id in ct.get_nearby_units() + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) == EntityType.BUILDER_BOT) + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + nearby_friendly_builders = count_nearby_friendly_builders(ct) + if (globalTitanium > 80 + 60 * self.numSpawned + and extra_spawn_allowed(nearby_friendly_builders)): + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Walk to an exact gunner firing seat and rebuild it indefinitely.""" + target = self._enemy_core_target() + if target is None: + return + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + best = None + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + + if best is None: + self.mapPf.moveTo(ct, target) + return + + _, seat, stand, facing = best + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_spawn_discipline/mapPathfinding.py b/bots/exp_spawn_discipline/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_spawn_discipline/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_spawn_discipline/symmetry.py b/bots/exp_spawn_discipline/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_spawn_discipline/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_surgical_counter/initialSpawning.py b/bots/exp_surgical_counter/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_surgical_counter/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_surgical_counter/main.py b/bots/exp_surgical_counter/main.py new file mode 100644 index 0000000..cf931f1 --- /dev/null +++ b/bots/exp_surgical_counter/main.py @@ -0,0 +1,867 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + selected = covered[0][4] + defender_pos = selected[3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + self.mapPf.moveTo(ct, target) + return + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _friendly_gunner_covers( + self, ct: Controller, target: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if target in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_surgical_counter/mapPathfinding.py b/bots/exp_surgical_counter/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_surgical_counter/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_surgical_counter/symmetry.py b/bots/exp_surgical_counter/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_surgical_counter/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_symmetry/initialSpawning.py b/bots/exp_symmetry/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_symmetry/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_symmetry/main.py b/bots/exp_symmetry/main.py new file mode 100644 index 0000000..ee91fbb --- /dev/null +++ b/bots/exp_symmetry/main.py @@ -0,0 +1,609 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_ENEMY_CORE = 11 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + ct.spawn_builder(spawnPos) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the first-spawned builder walls the enemy-side ring + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # picked by SPAWN ORDER via store slot 0 — entity ids interleave + # across teams, so an id threshold silently matched nobody as + # player B (t55 losses unchanged until this was fixed). + if self.turnsAlive == 1: + self.mySpawnIdx = ct.read_store(0) # count at my birth = my index + if (getattr(self, "mySpawnIdx", 99) <= 1 + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + curScore += 8 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and ct.get_id() > 4: # dont attack if u r broke and leave one bot for defense + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and ct.get_id() > 4): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_symmetry/mapPathfinding.py b/bots/exp_symmetry/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_symmetry/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_symmetry/symmetry.py b/bots/exp_symmetry/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_symmetry/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/meta-generalist-v1/README.md b/bots/meta-generalist-v1/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/meta-generalist-v1/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/meta-generalist-v1/initialSpawning.py b/bots/meta-generalist-v1/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/meta-generalist-v1/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/meta-generalist-v1/main.py b/bots/meta-generalist-v1/main.py new file mode 100644 index 0000000..e625940 --- /dev/null +++ b/bots/meta-generalist-v1/main.py @@ -0,0 +1,1252 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/meta-generalist-v1/mapPathfinding.py b/bots/meta-generalist-v1/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/meta-generalist-v1/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/meta-generalist-v1/symmetry.py b/bots/meta-generalist-v1/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/meta-generalist-v1/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/probe_order/main.py b/bots/probe_order/main.py new file mode 100644 index 0000000..18c0718 --- /dev/null +++ b/bots/probe_order/main.py @@ -0,0 +1,39 @@ +"""Instrumentation-only bot: records the exact order run() is called. + +Answers two things I asserted without checking: + 1. Is the bot passed as BOT_A actually Team A? + 2. Within a round, do ALL of Team A's units act before ANY of Team B's, + i.e. is there a genuine team-level turn order? + +Both sides run this same code, so the shared append-only log interleaves both +teams and shows the true global execution order. Does nothing else -- the log +is the whole point. +""" +import os + +LOG = os.environ.get("PROBE_LOG", "/tmp/probe_order.log") + + +class Player: + def __init__(self): + self._f = None + + def run(self, ct) -> None: + try: + r = ct.get_current_round() + if r > 6: # a few rounds is plenty; keep the log small + return + team = ct.get_team() + etype = ct.get_entity_type() + with open(LOG, "a") as f: + f.write(f"round={r} team={team} type={etype} id={ct.get_id()}\n") + # Spawn a few builders so there is a real multi-unit ordering to + # observe: team-blocked (all A then all B) vs interleaved by id. + if str(etype).endswith("CORE") and ct.get_action_cooldown() == 0: + pos = ct.get_position() + for d in ct.get_nearby_tiles(dist_sq=2): + if ct.can_spawn(d): + ct.spawn_builder(d) + break + except Exception: + pass diff --git a/bots/spar_barrier_fort/README.md b/bots/spar_barrier_fort/README.md new file mode 100644 index 0000000..faa2571 --- /dev/null +++ b/bots/spar_barrier_fort/README.md @@ -0,0 +1,6 @@ +# Historical barrier-fort sparring profile + +Exact `bots/kfort` source from commit `d6473f1`, before the later switch from +firing-seat barriers to guard conveyors. It is preserved only as a mechanistic +proxy for visible core-seat denial; it is not a promotion baseline or a clone of +any named opponent. diff --git a/bots/spar_barrier_fort/main.py b/bots/spar_barrier_fort/main.py new file mode 100644 index 0000000..8a5f992 --- /dev/null +++ b/bots/spar_barrier_fort/main.py @@ -0,0 +1,1515 @@ +"""Khaos rebuild — memoryless state-scoring bot (Pantheon architecture). + +Every builder, every turn: score seven candidate states in descending ceiling +order with early exit, run the single best. No mode flags, no multi-turn +commitments — targets are re-derived each turn from the bitmask map layer, +deconflicted through a Voronoi claim partition, with TTL caches for targets +that recently failed. + +States (ceiling): attack 9 · heal 8 · route 7.75 · secure 7.5 · harvest 4 · +disrupt 2 · explore 1. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +import mapanalysis +import mapinfo as mapinfo_mod +import path +import planes +import symmetry + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +DELTA_TO_DIR = { + (0, -1): Direction.NORTH, (0, 1): Direction.SOUTH, + (1, 0): Direction.EAST, (-1, 0): Direction.WEST, +} + +GLOBAL_AMMO_BUFFER = 20 +MAX_OPEN_BUILDERS = 5 +TTL_ROUNDS = 100 +CPU_SOFT_STOP = 6500 # stop scoring lower states past this many us + + +def manhattan(a, b): + return abs(a.x - b.x) + abs(a.y - b.y) + + +class Player: + def __init__(self): + self.mi = None + self.myNum = -1 + self.numSpawned = 0 + self.teamCore = None + self.teamCoreTiles = None + self.enemyCore = None + self.symTracker = None + self.fullmap = None + self.fullmapVer = -1 + self.routeField = None + self.routeVer = -1 + self._connected = set() # own conveyors whose chain reaches the core + self._homeDisc = None # cached home-guard disc for builder #1 + self.brokenBarriers = [] # own barriers we broke while walking + self._plantCache = None # score-plane best placement cache + self.enemyField = None # dist-to-enemy-core field (attack route) + self.enemyFieldVer = -1 + self.ttl = {} # (state, x, y) -> expiry round + self.stuck = 0 + self.lastPos = None + self.exploreTarget = None + # turret / launcher state + self.idleTurns = 0 + self.enemyBotHistory = {} + self.tapWait = 0 # sentinel one-tap coordination hold counter + + # ------------------------------------------------------------------ + def run(self, ct: Controller) -> None: + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.core(ct) + elif etype == EntityType.BUILDER_BOT: + self.builder(ct) + elif etype == EntityType.SENTINEL: + self.sentinel(ct) + elif etype == EntityType.GUNNER: + self.gunner(ct) + elif etype == EntityType.LAUNCHER: + self.launcher(ct) + + # ------------------------------------------------------------------ + # Core: fan-spawn opening, defensive spawn, ammo buffer + # ------------------------------------------------------------------ + def core(self, ct: Controller) -> None: + import math + pos = ct.get_position() + if self.mi is None: + w, h = ct.get_map_width(), ct.get_map_height() + self.mi = mapinfo_mod.MapInfo(w, h) + mapanalysis.reset_map(w, h) + rnd = ct.get_current_round() + ti = ct.get_global_resources() + # Pantheon spawn policy: baseline 200 until 12 allies, then 400 — + # spawn whenever titanium clears baseline + the builder's own cost. + # (Our old 350/400*scale gates stalled workforce growth exactly + # during establishment.) + baseline = 400 if ct.get_unit_count() >= 12 else 200 + wantSpawn = ( + ti >= baseline + ct.get_builder_bot_cost() + or (rnd >= 30 and ct.get_unit_count() <= 6 + and ti >= ct.get_builder_bot_cost() + 60)) + if wantSpawn: + candidates = [] + for dx in (-1, 0, 1, 2): + for dy in (-1, 0, 1, 2): + if 0 <= dx <= 1 and 0 <= dy <= 1: + continue + p = Position(pos.x + dx, pos.y + dy) + if ct.can_spawn(p): + candidates.append(p) + if candidates: + ccx, ccy = pos.x + 0.5, pos.y + 0.5 + pick = None + # defensive: intruder at the base with no ally on it + enemyPos, enemyDist, allies = None, 1e9, [] + for eid in ct.get_nearby_units(): + ep = ct.get_position(eid) + if ct.get_team(eid) == ct.get_team(): + if ct.get_entity_type(eid) == EntityType.BUILDER_BOT: + allies.append(ep) + continue + d = (ep.x - ccx) ** 2 + (ep.y - ccy) ** 2 + if d < enemyDist: + enemyDist, enemyPos = d, ep + if enemyPos is not None and enemyDist <= 18 and not any( + a.distance_squared(enemyPos) <= 8 for a in allies): + pick = min(candidates, key=lambda p: manhattan(p, enemyPos)) + elif self.numSpawned < 4: + w, h = ct.get_map_width(), ct.get_map_height() + primary = math.atan2(h / 2 - ccy, w / 2 - ccx) + target = primary + (-1.178, -0.393, 0.393, 1.178)[self.numSpawned] + tx, ty = math.cos(target), math.sin(target) + + def align(p): + vx, vy = p.x + 0.5 - ccx, p.y + 0.5 - ccy + norm = math.hypot(vx, vy) or 1.0 + return -(vx * tx + vy * ty) / norm + pick = min(candidates, key=align) + else: + pick = candidates[0] + ct.spawn_builder(pick) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) + # distress: hurt core or enemy building in core vision -> broadcast on + # slot 7 so fanned-out builders converge to heal/counter-battery. + distress = ct.get_hp() < ct.get_max_hp() + if not distress: + for bid in ct.get_nearby_buildings(): + if ct.get_team(bid) != ct.get_team(): + distress = True + break + if distress: + ct.write_store(7, rnd) + # ammo buffer (raised while distressed so counter-turrets can fire) + buffer = 40 if distress else GLOBAL_AMMO_BUFFER + cur = ct.get_global_ammo() + if cur < buffer: + spare = ct.get_global_resources() - ct.get_builder_bot_cost() + amount = min(buffer - cur, spare) + if amount > 0 and ct.can_convert_ammo(amount): + ct.convert_ammo(amount) + + # ------------------------------------------------------------------ + # Builder: the memoryless spine + # ------------------------------------------------------------------ + def builder(self, ct: Controller) -> None: + mi = self._setup(ct) + mi.update_vision(ct) + mi.absorb(ct) + self._updateCores(ct) + self._updateSymmetry(ct) + + rnd = ct.get_current_round() + pos = ct.get_position() + if self.lastPos == pos: + self.stuck += 1 + else: + self.stuck = 0 + self.lastPos = pos + + # ttl sweep + if rnd % 16 == 0: + self.ttl = {k: v for k, v in self.ttl.items() if v > rnd} + + # death memory (Ijti fix): ban tiles where our turrets died + if mi.turret_losses: + for (lx, ly) in mi.turret_losses: + self.ttl[('plant', lx, ly)] = rnd + 80 + mi.turret_losses.clear() + self._plantCache = None + + # claims context: my bit vs other friendly bots + my_bit = mi.bit(pos.x, pos.y) + others = mi.own_bots & ~my_bit + + states = ( + (9.0, self._score_attack, self._run_attack), + (8.0, self._score_heal, self._run_heal), + (7.75, self._score_route, self._run_route), + (7.5, self._score_secure, self._run_secure), + (4.0, self._score_harvest, self._run_harvest), + (2.0, self._score_disrupt, self._run_disrupt), + (1.0, self._score_explore, self._run_explore), + ) + best, bestScore, bestData = None, 0.0, None + for ceil, scorefn, runfn in states: + if bestScore >= ceil: + break + if ct.get_cpu_time_elapsed() > CPU_SOFT_STOP and best is not None: + break + s, data = scorefn(ct, mi, my_bit, others) + if s > bestScore: + best, bestScore, bestData = runfn, s, data + if best is not None: + best(ct, mi, bestData) + + # passive extras with leftover action/cpu + self._rebuildBroken(ct, mi) + self._ringGuns(ct, mi) + self._tryChokeBlock(ct, mi) + self._denyHomeSiege(ct, mi) + self._healAdjacent(ct) + mi.share(ct, self.myNum, self.teamCore) + self._advanceAnalysis(ct) + + # ---- setup helpers ---- + def _setup(self, ct): + if self.mi is None: + w, h = ct.get_map_width(), ct.get_map_height() + self.mi = mapinfo_mod.MapInfo(w, h) + mapanalysis.configure_map(w, h) + self.mi.on_block_change = ( + lambda x, y, blocking: mapanalysis.note_structural_tile( + x, y, blocking, w, h)) + if self.myNum == -1: + self.myNum = ct.read_store(0) + 1 + return self.mi + + def _updateCores(self, ct): + if self.teamCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) == ct.get_team()): + self.teamCore = ct.get_position(bid) + if self.enemyCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) != ct.get_team()): + self.enemyCore = ct.get_position(bid) + + def _updateSymmetry(self, ct): + mi = self.mi + if self.teamCore is None: + return + if self.symTracker is None: + self.symTracker = symmetry.SymmetryTracker(mi.w, mi.h) + if self.fullmapVer != mi.struct_version or self.fullmap is None: + fm = [[-1] * mi.h for _ in range(mi.w)] + for x in range(mi.w): + col = fm[x] + for y in range(mi.h): + b = 1 << (x + y * mi.w) + if not (mi.seen & b): + continue + if mi.walls & b: + col[y] = 2 + elif mi.blocked & b: + col[y] = 3 + elif mi.ore & b: + col[y] = 1 + else: + col[y] = 0 + self.fullmap = fm + self.fullmapVer = mi.struct_version + self.symTracker.update(self.fullmap, mi.struct_version, self.teamCore) + + def _enemyCoreGuess(self): + if self.enemyCore is not None: + return self.enemyCore + if self.symTracker is not None and self.teamCore is not None: + cx, cy = self.symTracker.enemy_core(self.teamCore) + if 0 <= cx < self.mi.w - 1 and 0 <= cy < self.mi.h - 1: + return Position(cx, cy) + return None + + def _homeMask(self): + """Tiles within squared distance 100 of the core (Pantheon: the very + first builder restricts all its targets to this disc = home guard).""" + mi = self.mi + if self._homeDisc is not None or self.teamCore is None: + return self._homeDisc or 0 + cx, cy = self.teamCore.x, self.teamCore.y + m = 0 + for x in range(max(0, cx - 10), min(mi.w, cx + 11)): + for y in range(max(0, cy - 10), min(mi.h, cy + 11)): + if (x - cx) ** 2 + (y - cy) ** 2 <= 100: + m |= mi.bit(x, y) + self._homeDisc = m + return m + + def _coreMask(self): + mi = self.mi + if self.teamCore is None: + return 0 + m = 0 + for dx in (0, 1): + for dy in (0, 1): + x, y = self.teamCore.x + dx, self.teamCore.y + dy + if 0 <= x < mi.w and 0 <= y < mi.h: + m |= mi.bit(x, y) + return m + + # ---- movement ---- + def _moveToward(self, ct, targets_mask, avoid=0): + if ct.get_move_cooldown() != 0: + return + mi = self.mi + pos = ct.get_position() + step = path.next_step(mi, (pos.x, pos.y), targets_mask, avoid) + if step is not None: + d = DELTA_TO_DIR[step] + dest = pos.add(d) + # break-walk own barriers (Pantheon): destroy is free, remember + # the tile so we rebuild it once we've stepped off. + if (self._inBounds(dest) + and mi.own_barriers & mi.bit(dest.x, dest.y) + and not ct.can_move(d) + and ct.is_in_vision(dest) and ct.can_destroy(dest)): + ct.destroy(dest) + mi.own_barriers &= ~mi.bit(dest.x, dest.y) + mi.note_tile(dest.x, dest.y, mapinfo_mod.T_EMPTY) + if len(self.brokenBarriers) < 4: + self.brokenBarriers.append(dest) + if ct.can_move(d): + ct.move(d) + self.stuck = 0 + return + self.stuck += 1 + if self.stuck > 2 + (ct.get_id() % 8): + dirs = [d for d in CARDINALS if ct.can_move(d)] + if dirs: + ct.move(random.choice(dirs)) + + # ------------------------------------------------------------------ + # STATE: attack (9) — plant turrets on enemy buildings / push their core + # ------------------------------------------------------------------ + def _score_attack(self, ct, mi, my_bit, others): + rnd = ct.get_current_round() + ti = ct.get_global_resources() + if ti < ct.get_gunner_cost() + 40: + return 0.0, None + # DEFENSIVE counter-battery: enemy building near OUR core — every + # builder may respond (no role gate), claims still deconflict. + if self.teamCore is not None: + home = 0 + for (x, y) in mi.enemy_buildings: + if self.ttl.get(('atk', x, y), 0) > rnd: + continue + if abs(x - self.teamCore.x) + abs(y - self.teamCore.y) <= 10: + home |= mi.bit(x, y) + if home: + mine = path.claims(mi, my_bit, others, home) + if mine: + return 9.0, ('bld', mine) + if rnd < 30 or self.myNum % 3 != 0 or self.myNum == 1: + return 0.0, None # builder #1 is the home guard, never sieges + guess = self._enemyCoreGuess() + # bit-sliced plane placement (Pantheon): score EVERY legal gunner + # placement board-wide, take the best above threshold. + plant = self._bestPlant(ct, mi) + if plant is not None: + score, px, py, fk = plant + # geographic money gate on the PLACEMENT tile + if self.teamCore is not None and guess is not None: + dHome = abs(px - self.teamCore.x) + abs(py - self.teamCore.y) + dEnemy = abs(px - guess.x) + abs(py - guess.y) + deep = dHome > dEnemy + else: + deep = False + if not deep or ti >= 150: + mine = path.claims(mi, my_bit, others, mi.bit(px, py)) + if mine: + return 9.0, ('plant', (px, py, fk)) + # fallback: walk at remembered enemy buildings (old greedy pathway) + cands = 0 + for (x, y) in mi.enemy_buildings: + key = ('atk', x, y) + if self.ttl.get(key, 0) > rnd: + continue + if self.teamCore is not None and guess is not None: + dHome = abs(x - self.teamCore.x) + abs(y - self.teamCore.y) + dEnemy = abs(x - guess.x) + abs(y - guess.y) + onOurHalf = dHome <= dEnemy + else: + onOurHalf = True + if onOurHalf or ti >= 150: + cands |= mi.bit(x, y) + if cands: + mine = path.claims(mi, my_bit, others, cands) + if mine: + return 8.9, ('bld', mine) + return 0.0, None + if guess is not None: + return 6.0, ('core', guess) + return 0.0, None + + PLANT_VALUES = None # built lazily: EntityType -> placement value + + def _bestPlant(self, ct, mi): + """Best gunner placement from the score planes, cached per + (struct, enemy-set). Threshold 20 = better than a lone barrier.""" + if Player.PLANT_VALUES is None: + Player.PLANT_VALUES = { + EntityType.CORE: 60, EntityType.GUNNER: 40, + EntityType.SENTINEL: 40, EntityType.LAUNCHER: 20, + EntityType.CONVEYOR: 15, EntityType.SPLITTER: 18, + EntityType.HARVESTER: 8, EntityType.BARRIER: 5, + } + key = (mi.struct_version, + tuple(sorted((k, str(v)) + for k, v in mi.enemy_buildings.items()))) + if self._plantCache is not None and self._plantCache[0] == key: + return self._plantCache[1] + by_value = {} + for (x, y), etype in mi.enemy_buildings.items(): + v = Player.PLANT_VALUES.get(etype, 5) + by_value.setdefault(v, [0])[0] = by_value.get(v, [0])[0] \ + | mi.bit(x, y) + class_masks = [(v, m[0]) for v, m in by_value.items()] + valid = (mi.seen & ~mi.walls & ~mi.blocked & ~mi.ore + & ~mi.own_conveyors & ~mi.own_barriers + & ~mi.expand(mi.expand(mi.enemy_bots))) + # death memory (Ijti fix): freshly-fatal tiles are banned + rnd = ct.get_current_round() + for (kind, kx, ky), exp in self.ttl.items(): + if kind == 'plant' and exp > rnd: + valid &= ~mi.bit(kx, ky) + best = None + if class_masks and valid: + # threat penalty (Ijti fix): unthreatened placements first; + # threatened only at 2x value (trade, not feeding tube) + threat = mi.threat() + best = planes.best_placement(mi, class_masks, valid & ~threat) + if best is None or best[0] < 20: + hot = planes.best_placement(mi, class_masks, valid & threat) + if hot is not None and hot[0] >= 40 \ + and (best is None or hot[0] > best[0]): + best = hot + if best is not None and best[0] < 20: + best = None + self._plantCache = (key, best) + return best + + def _enemyFieldCached(self, mi, guess): + key = (mi.struct_version, guess.x, guess.y) + if self.enemyField is not None and self.enemyFieldVer == key: + return self.enemyField + m = 0 + for dx in (0, 1): + for dy in (0, 1): + x, y = guess.x + dx, guess.y + dy + if 0 <= x < mi.w and 0 <= y < mi.h: + m |= mi.bit(x, y) + self.enemyField = path.dist_field(mi, mi.expand(m)) + self.enemyFieldVer = key + return self.enemyField + + def _run_attack(self, ct, mi, data): + kind, payload = data + pos = ct.get_position() + rnd = ct.get_current_round() + if kind == 'core': + # ATTACK ROUTE (Pantheon override): while marching on the enemy + # core, lay a conveyor trail once we're geographically ahead + # (dist-to-them <= 1.5x dist-home). Walkable for us, area denial + # for them; planes convert the head into a gunner on contact. + if (ct.get_action_cooldown() == 0 + and ct.get_global_resources() + >= ct.get_conveyor_cost() + 120): + ef = self._enemyFieldCached(mi, payload) + hf = self._routeFieldCached(ct) + idx = pos.x + pos.y * mi.w + if (ef is not None and hf is not None + and ef[idx] < 4096 and hf[idx] < 4096 + and ef[idx] <= 1.5 * hf[idx]): + best = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = pos.x + dx, pos.y + dy + if not (0 <= nx < mi.w and 0 <= ny < mi.h): + continue + b = mi.bit(nx, ny) + if (mi.walls | mi.blocked | mi.own_conveyors + | mi.own_barriers | mi.ore) & b: + continue + d = ef[nx + ny * mi.w] + if best is None or d < best[0]: + best = (d, nx, ny) + if best is not None and best[0] < ef[idx]: + _, lx, ly = best + link = Position(lx, ly) + # face further downhill toward the enemy core + fb = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + fx, fy = lx + dx, ly + dy + if not (0 <= fx < mi.w and 0 <= fy < mi.h): + continue + fd = ef[fx + fy * mi.w] + if fb is None or fd < fb[0]: + fb = (fd, dx, dy) + if fb is not None: + facing = DELTA_TO_DIR[(fb[1], fb[2])] + if (ct.is_in_vision(link) + and ct.can_build_conveyor(link, facing)): + ct.build_conveyor(link, facing) + mi.own_conveyors |= mi.bit(lx, ly) + mi.own_conv_facing[(lx, ly)] = (fb[1], fb[2]) + m = mi.bit(payload.x, payload.y) + self._moveToward(ct, mi.expand(mi.expand(m))) + return + if kind == 'plant': + px, py, fk = payload + tile = Position(px, py) + facing = {'E': Direction.EAST, 'W': Direction.WEST, + 'N': Direction.NORTH, 'S': Direction.SOUTH}[fk] + tb = mi.bit(px, py) + if pos == tile: + # standing on the placement: step aside + self._moveToward(ct, mi.expand(tb) & mi.passable() & ~tb) + return + if pos.distance_squared(tile) <= 2 \ + and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tile) and ct.can_build_gunner(tile, facing): + ct.build_gunner(tile, facing) + self._plantCache = None + return + self.ttl[('atk', px, py)] = rnd + 20 + self._plantCache = None + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + return + targets = payload + tb = targets & -targets + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + dist = manhattan(pos, tpos) + if dist <= 3 and ct.get_action_cooldown() == 0: + # place a turret on an adjacent tile with line to the target + facing = self._cardinalTo(pos, tpos) + placed = False + for d in CARDINALS: + p = pos.add(d) + if not self._inBounds(p) or not ct.is_in_vision(p): + continue + f = self._cardinalTo(p, tpos) + if f == Direction.CENTRE: + continue + if ct.can_build_gunner(p, f): + ct.build_gunner(p, f) + placed = True + break + if (ct.get_global_resources() >= ct.get_sentinel_cost() + 40 + and ct.can_build_sentinel(p, f)): + ct.build_sentinel(p, f) + placed = True + break + if placed: + self.ttl[('atk', tx, ty)] = rnd + 40 + return + if facing == Direction.CENTRE: + self.ttl[('atk', tx, ty)] = rnd + TTL_ROUNDS + return + if dist <= 1: + # cannot place and already adjacent: back off this target a while + self.ttl[('atk', tx, ty)] = rnd + 30 + self._moveToward(ct, mi.expand(targets) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: heal (8) — repair damaged own buildings, deter intruders + # ------------------------------------------------------------------ + def _score_heal(self, ct, mi, my_bit, others): + damaged = 0 + # core distress broadcast: converge on the core even when it's out of + # our own vision — 2 medics out-heal a sentinel's grind (8 vs 6/round). + distress = ct.read_store(7) + if (distress > 0 and ct.get_current_round() - distress < 12 + and self.teamCore is not None): + damaged |= self._coreMask() + for bid in ct.get_nearby_buildings(): + if ct.get_team(bid) != ct.get_team(): + continue + if ct.get_hp(bid) + 2 < ct.get_max_hp(bid): + p = ct.get_position(bid) + damaged |= mi.bit(p.x, p.y) + if damaged: + mine = path.claims(mi, my_bit, others, damaged) + if mine: + return 8.0, ('heal', mine) + # Pantheon: own sentinel trading with an enemy sentinel counts as very + # damaged — medics pre-park. Scores 7, BELOW route's 7.75 (the paper's + # exact number; 8.0 parked the workforce and starved the economy). + trading = 0 + for (x, y), tk in mi.own_turrets.items(): + if tk != 'S': + continue + for (ex, ey), ekind in mi.enemy_turrets.items(): + if ekind == 'S' and (x - ex) ** 2 + (y - ey) ** 2 <= 32: + trading |= mi.bit(x, y) + break + if trading: + mine = path.claims(mi, my_bit, others, trading) + if mine: + return 7.0, ('heal', mine) + # Pantheon chase zone: 8 pathing steps around our conveyor network + # (was 2 tiles) — intruders get hunted before they reach the chain. + zone = mi.own_conveyors + passable = mi.passable() + for _ in range(8): + zone = mi.expand(zone) & (passable | zone) + intruders = mi.enemy_bots & zone + if intruders: + mine = path.claims(mi, my_bit, others, intruders) + if mine: + return 7.9, ('chase', mine) + return 0.0, None + + def _run_heal(self, ct, mi, data): + kind, mask = data + pos = ct.get_position() + tb = mask & -mask + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + if kind == 'heal': + if pos.distance_squared(tpos) <= 2: + if ct.can_heal(tpos): + ct.heal(tpos) + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + return + # chase: stand next to the intruder; the launcher/turret net does the + # rest, and our body denies conveyor-chip tiles + if pos.distance_squared(tpos) <= 2: + if ct.get_action_cooldown() == 0: + for d in CARDINALS: + p = pos.add(d) + if (self._inBounds(p) and ct.is_in_vision(p) + and manhattan(p, tpos) <= 1 + and ct.can_build_launcher(p) + and ct.get_global_resources() + >= ct.get_launcher_cost() + 60): + ct.build_launcher(p) + return + return + # Pantheon counter-launcher fallback: chase can't PATH to the + # intruder (their launcher zone / barriers) -> zone them back + # instead of giving up. + step = path.next_step(mi, (pos.x, pos.y), + mi.expand(tb) & mi.passable()) + if step is None: + if (manhattan(pos, tpos) <= 3 and ct.get_action_cooldown() == 0 + and ct.get_global_resources() + >= ct.get_launcher_cost() + 60): + for d in CARDINALS: + p = pos.add(d) + if (self._inBounds(p) and ct.is_in_vision(p) + and manhattan(p, tpos) <= 2 + and ct.can_build_launcher(p)): + ct.build_launcher(p) + return + self._moveToward(ct, mi.expand(mi.expand(tb)) & mi.passable()) + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: route (7.75) — stateless chain building down the core gradient + # ------------------------------------------------------------------ + def _connectedConveyors(self, mi): + """Own conveyors whose chain (by facing) reaches the core — legal + merge targets for new routes (Pantheon: routes may end on existing + conveyors). Fixpoint over remembered facings.""" + coreMask = self._coreMask() + core_xy = set() + m = coreMask + while m: + lsb = m & -m + core_xy.add(mi.xy(lsb)) + m ^= lsb + connected = set() + changed = True + while changed: + changed = False + for (x, y), (dx, dy) in mi.own_conv_facing.items(): + if (x, y) in connected: + continue + out = (x + dx, y + dy) + if out in core_xy or out in connected: + connected.add((x, y)) + changed = True + return connected + + def _routeFieldCached(self, ct): + mi = self.mi + key = (mi.struct_version, len(mi.own_conv_facing), + tuple(sorted(mi.enemy_turrets))) + if self.routeField is not None and self.routeVer == key: + return self.routeField + coreMask = self._coreMask() + if not coreMask: + return None + # Pantheon merge targets: the core PLUS every conveyor already + # connected to it — new chains join existing trunks instead of each + # harvester paying for a private line the whole way home. LOADED + # trunk tiles are excluded (P6): a full conveyor can't accept a new + # stack, so merging there jams the junction (their +4 penalty, made + # binary by dev23's hold-1-stack rule). + self._connected = self._connectedConveyors(mi) + targets = coreMask + for (x, y) in self._connected: + targets |= mi.bit(x, y) + targets &= ~mi.own_loaded | coreMask + # Pantheon universal AVOID mask (p7): tiles covered by enemy turrets + # are HARD-excluded from conveyor route planning, not soft-costed — + # a link placed under a known gun is titanium fed to the enemy + # (Landers vault r139: we rebuilt one threatened link 7 times). + # Existing own conveyors stay routable so trunks aren't orphaned. + avoid = mi.threat() & ~mi.own_conveyors & ~coreMask + self.routeField = path.dist_field(mi, targets, avoid_mask=avoid) + self.routeVer = key + return self.routeField + + @staticmethod + def _chainRoot(mi, hx, hy): + """Adjacent own conveyor whose output is NOT the harvester — a real + chain root. Inward-facing guards don't count (Khaos guard mask).""" + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + cx, cy = hx + dx, hy + dy + f = mi.own_conv_facing.get((cx, cy)) + if f is None: + continue + if (cx + f[0], cy + f[1]) != (hx, hy): + return (cx, cy) + return None + + def _downhillLink(self, mi, field, x, y): + """Best empty neighbour to lay the next conveyor on, plus the facing + that conveyor should have (toward ITS best downhill neighbour).""" + best = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if not (0 <= nx < mi.w and 0 <= ny < mi.h): + continue + b = mi.bit(nx, ny) + if (mi.walls | mi.blocked | mi.own_conveyors) & b: + continue + d = field[nx + ny * mi.w] + if best is None or d < best[0]: + best = (d, nx, ny) + if best is None or best[0] >= 4096: + return None, None + _, lx, ly = best + fbest = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + fx, fy = lx + dx, ly + dy + if not (0 <= fx < mi.w and 0 <= fy < mi.h): + continue + fd = field[fx + fy * mi.w] + if fbest is None or fd < fbest[0]: + fbest = (fd, dx, dy) + if fbest is None: + return None, None + return Position(lx, ly), DELTA_TO_DIR[(fbest[1], fbest[2])] + + def _orphanHarvesters(self, ct, mi): + rnd = ct.get_current_round() + orphans = 0 + for (x, y) in mi.own_harvesters: + key = ('route', x, y) + if self.ttl.get(key, 0) > rnd: + continue + if self._chainRoot(mi, x, y) is None: + orphans |= mi.bit(x, y) + return orphans + + def _deadEnds(self, ct, mi): + """Own conveyors whose output tile has no continuation (Khaos + dead-end route targets). Guards (facing into a harvester) excluded.""" + rnd = ct.get_current_round() + coreMask = self._coreMask() + ends = 0 + for (x, y), (dx, dy) in mi.own_conv_facing.items(): + if self.ttl.get(('route', x, y), 0) > rnd: + continue + ox, oy = x + dx, y + dy + if (ox, oy) in mi.own_harvesters: + continue # inward guard, carries nothing + if not (0 <= ox < mi.w and 0 <= oy < mi.h): + ends |= mi.bit(x, y) + continue + ob = mi.bit(ox, oy) + if ob & coreMask: + continue # delivers into the core: chain complete + if mi.own_conveyors & ob: + continue # continues into another conveyor/splitter + ends |= mi.bit(x, y) + return ends + + def _score_route(self, ct, mi, my_bit, others): + if self.teamCore is None: + return 0.0, None + cands = self._orphanHarvesters(ct, mi) | self._deadEnds(ct, mi) + if not cands: + return 0.0, None + mine = path.claims(mi, my_bit, others, cands) + if not mine: + return 0.0, None + near_enemy = mi.expand(mi.expand(mi.enemy_bots)) + important = mine & near_enemy + return (7.75 if important else 5.0), mine + + def _run_route(self, ct, mi, mask): + field = self._routeFieldCached(ct) + rnd = ct.get_current_round() + hb = mask & -mask + hx, hy = mi.xy(hb) + if field is None: + return + coreMask = self._coreMask() + link = facing = None + if (hx, hy) in mi.own_conv_facing: + root = (hx, hy) # dead-end conveyor target: walk from it + else: + root = self._chainRoot(mi, hx, hy) + # P6 cost gate (Pantheon cost(d)): before STARTING a fresh chain, + # check we can afford ~the whole line. 0.65 discount — income + # keeps arriving while we build. + d = field[hx + hy * mi.w] + if d < 4096: + est = 3 * d * ct.get_scale_percent() / 100 * 0.65 + if ct.get_global_resources() < est: + self.ttl[('route', hx, hy)] = rnd + 30 + return + if root is None: + link, facing = self._downhillLink(mi, field, hx, hy) + else: + # follow the actual chain by facings until it breaks / reaches core + cur = root + visited = set() + for _ in range(mi.w + mi.h): + if cur in visited: + self.ttl[('route', hx, hy)] = rnd + 30 + return + visited.add(cur) + f = mi.own_conv_facing.get(cur) + if f is None: + self.ttl[('route', hx, hy)] = rnd + 15 + return + ox, oy = cur[0] + f[0], cur[1] + f[1] + if not (0 <= ox < mi.w and 0 <= oy < mi.h): + link, facing = self._downhillLink(mi, field, + cur[0], cur[1]) + break + ob = mi.bit(ox, oy) + if ob & coreMask or (ox, oy) in self._connected: + self.ttl[('route', hx, hy)] = rnd + 30 # chain complete + return + if (ox, oy) in mi.own_conv_facing: + cur = (ox, oy) + continue + if mi.own_conveyors & ob: + # splitter / facing not yet observed: assume connected + self.ttl[('route', hx, hy)] = rnd + 15 + return + if (mi.walls | mi.blocked) & ob: + link, facing = self._downhillLink(mi, field, + cur[0], cur[1]) + break + # empty output tile: that's the missing link + link = Position(ox, oy) + fbest = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + fx, fy = ox + dx, oy + dy + if not (0 <= fx < mi.w and 0 <= fy < mi.h): + continue + fd = field[fx + fy * mi.w] + if fbest is None or fd < fbest[0]: + fbest = (fd, dx, dy) + if fbest is None: + return + facing = DELTA_TO_DIR[(fbest[1], fbest[2])] + break + if link is None: + # Pantheon: an unroutable dead-end near enemies gets CAPPED with + # a barrier on its output so they can't exploit/extend it + f = mi.own_conv_facing.get((hx, hy)) + if f is not None and mi.enemy_bots: + near = mi.expand(mi.expand(mi.expand(mi.expand( + mi.bit(hx, hy))))) + if near & mi.enemy_bots and ct.get_action_cooldown() == 0: + ox, oy = hx + f[0], hy + f[1] + if 0 <= ox < mi.w and 0 <= oy < mi.h: + cap = Position(ox, oy) + pos0 = ct.get_position() + if (pos0.distance_squared(cap) <= 2 + and ct.is_in_vision(cap) + and ct.can_build_barrier(cap)): + ct.build_barrier(cap) + mi.own_barriers |= mi.bit(ox, oy) + mi.note_tile(ox, oy, mapinfo_mod.T_BLOCK) + self.ttl[('route', hx, hy)] = rnd + 20 + return + pos = ct.get_position() + lb = mi.bit(link.x, link.y) + if pos == link: + # standing on the link tile: step aside so we can build it + self._moveToward(ct, mi.expand(lb) & mi.passable() & ~lb) + return + if pos.distance_squared(link) <= 2 and ct.get_action_cooldown() == 0: + if ct.is_in_vision(link) and ct.can_build_conveyor(link, facing): + ct.build_conveyor(link, facing) + mi.own_conveyors |= lb + mi.own_conv_facing[(link.x, link.y)] = ( + mapinfo_mod.DIR_DELTA[facing]) + return + self.ttl[('route', hx, hy)] = rnd + 10 + return + self._moveToward(ct, mi.expand(lb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: secure (7.5) — guard-conveyor exposed harvesters near enemies + # ------------------------------------------------------------------ + def _score_secure(self, ct, mi, my_bit, others): + if not mi.enemy_buildings and not mi.enemy_bots: + return 0.0, None + danger = 0 + for (x, y) in mi.enemy_buildings: + danger |= mi.bit(x, y) + danger = mi.expand(mi.expand(mi.expand(danger))) | \ + mi.expand(mi.expand(mi.enemy_bots)) + exposed = 0 + for (x, y) in mi.own_harvesters: + b = mi.bit(x, y) + if not (danger & mi.expand(b)): + continue + open_sides = mi.expand(b) & ~b & mi.passable() & ~mi.own_conveyors + if open_sides: + exposed |= open_sides + if not exposed: + return 0.0, None + mine = path.claims(mi, my_bit, others, exposed) + if not mine: + return 0.0, None + return 7.5, mine + + def _run_secure(self, ct, mi, mask): + pos = ct.get_position() + tb = mask & -mask + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + if pos.distance_squared(tpos) <= 2 and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tpos): + # find the harvester this side belongs to + harv = None + for d in CARDINALS: + hp = tpos.add(d) + if (self._inBounds(hp) + and (hp.x, hp.y) in mi.own_harvesters): + harv = (hp, d) + break + if harv is None: + return + hp, inward = harv + # Khaos: if this side is the harvester's downhill side, face + # OUTWARD so the guard doubles as the chain root; otherwise + # face INTO the harvester so it never carries resources. + facing = inward + field = self._routeFieldCached(ct) + if field is not None: + dlink, dfacing = self._downhillLink(mi, field, hp.x, hp.y) + if dlink is not None and dlink == tpos: + facing = dfacing + if ct.can_build_conveyor(tpos, facing): + ct.build_conveyor(tpos, facing) + mi.own_conveyors |= mi.bit(tpos.x, tpos.y) + mi.own_conv_facing[(tpos.x, tpos.y)] = ( + mapinfo_mod.DIR_DELTA[facing]) + return + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: harvest (4) + # ------------------------------------------------------------------ + def _score_harvest(self, ct, mi, my_bit, others): + rnd = ct.get_current_round() + # exclude only ore in the CLOSE disc of an enemy turret. The full + # facing-agnostic band mask vetoed most of the ore field on turret-y + # maps (quarry: 5-10k mined vs botv2's 18k) — band-edge mining is + # viable with heal upkeep (v29 lesson). + hot = 0 + for (x, y) in mi.enemy_turrets: + hot |= mi.bit(x, y) + hot = mi.expand(mi.expand(hot)) + cands = mi.ore & mi.seen & ~mi.blocked & ~mi.walls & ~hot + pruned = 0 + for lsb in mi.iter_bits(cands): + x, y = mi.xy(lsb) + if (x, y) in mi.own_harvesters: + continue + if self.ttl.get(('harv', x, y), 0) > rnd: + continue + pruned |= lsb + if self.myNum == 1: + pruned &= self._homeMask() # home guard mines home ore only + if not pruned: + return 0.0, None + mine = path.claims(mi, my_bit, others, pruned) + if not mine: + return 0.0, None + if ct.get_global_resources() < ct.get_harvester_cost(): + return 0.5, mine + return 4.0, mine + + def _run_harvest(self, ct, mi, mask): + pos = ct.get_position() + target = path.closest(mi, mi.bit(pos.x, pos.y), mask) + if not target: + target = mask & -mask + tx, ty = mi.xy(target) + tpos = Position(tx, ty) + rnd = ct.get_current_round() + if pos == tpos: + # standing on the ore: sidestep so we can build it + self._moveToward(ct, mi.expand(target) & mi.passable() & ~target) + return + if pos.distance_squared(tpos) <= 2 and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tpos) and ct.can_build_harvester(tpos): + ct.build_harvester(tpos) + mi.own_harvesters[(tx, ty)] = True + return + self.ttl[('harv', tx, ty)] = rnd + 25 + return + self._moveToward(ct, mi.expand(target) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: disrupt (2) — barrier enemy-half ore when rich + # ------------------------------------------------------------------ + def _score_disrupt(self, ct, mi, my_bit, others): + if self.myNum == 1: + return 0.0, None # home guard stays home + if ct.get_global_resources() < 5 * ct.get_harvester_cost(): + return 0.0, None + guess = self._enemyCoreGuess() + if guess is None or self.teamCore is None: + return 0.0, None + rnd = ct.get_current_round() + cands = 0 + for lsb in mi.iter_bits(mi.ore & mi.seen & ~mi.blocked + & ~mi.threat()): + x, y = mi.xy(lsb) + p = Position(x, y) + if manhattan(p, guess) < manhattan(p, self.teamCore) - 2: + if self.ttl.get(('dis', x, y), 0) <= rnd: + cands |= lsb + if not cands: + return 0.0, None + mine = path.claims(mi, my_bit, others, cands) + if not mine: + return 0.0, None + return 2.0, mine + + def _run_disrupt(self, ct, mi, mask): + pos = ct.get_position() + tb = mask & -mask + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + rnd = ct.get_current_round() + if pos.distance_squared(tpos) <= 2 and pos != tpos \ + and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tpos) and ct.can_build_barrier(tpos): + ct.build_barrier(tpos) + self.ttl[('dis', tx, ty)] = rnd + 1000 + return + self.ttl[('dis', tx, ty)] = rnd + 50 + return + if pos == tpos: + self._moveToward(ct, mi.expand(tb) & mi.passable() & ~tb) + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: explore (1) + # ------------------------------------------------------------------ + def _score_explore(self, ct, mi, my_bit, others): + return 1.0, None + + def _run_explore(self, ct, mi, _): + pos = ct.get_position() + if (self.exploreTarget is not None + and manhattan(pos, self.exploreTarget) <= 2): + self.exploreTarget = None + if self.exploreTarget is None or self.stuck > 4: + # home guard: patrol the undenied siege-ray tiles first so the + # denial wall goes up before anyone arrives to use those tiles + if self.myNum == 1 and self.teamCore is not None: + todo = [] + for (x, y) in self._siegeRayTiles(self.teamCore): + p = Position(x, y) + b = mi.bit(x, y) + if (mi.walls | mi.blocked | mi.own_barriers) & b: + continue + if ct.is_in_vision(p) and ( + ct.get_tile_env(p) == Environment.ORE_TITANIUM + or ct.get_tile_building_id(p) is not None): + continue + todo.append((x, y)) + if todo: + x, y = min(todo, key=lambda t: (pos.x - t[0]) ** 2 + + (pos.y - t[1]) ** 2) + self.exploreTarget = Position(x, y) + self._moveToward(ct, mi.expand(mi.bit(x, y)) + & mi.passable()) + return + frontier = mi.frontier() & mi.passable() + if self.myNum == 1: + frontier &= self._homeMask() # guard patrols home only + picked = None + if frontier: + bits = list(mi.iter_bits(frontier)) + picked = random.choice(bits[:64]) + if picked: + x, y = mi.xy(picked) + self.exploreTarget = Position(x, y) + elif self.myNum == 1 and self.teamCore is not None: + self.exploreTarget = Position( + max(0, min(mi.w - 1, + self.teamCore.x + random.randint(-4, 4))), + max(0, min(mi.h - 1, + self.teamCore.y + random.randint(-4, 4)))) + else: + self.exploreTarget = Position(random.randrange(mi.w), + random.randrange(mi.h)) + t = self.exploreTarget + self._moveToward(ct, mi.bit(t.x, t.y)) + + # ------------------------------------------------------------------ + # passive extras + # ------------------------------------------------------------------ + def _rebuildBroken(self, ct, mi): + """Replace own barriers we destroyed to walk through (Pantheon).""" + if not self.brokenBarriers or ct.get_action_cooldown() != 0: + return + pos = ct.get_position() + for p in list(self.brokenBarriers): + if pos == p: + continue # still standing on it; rebuild after stepping off + if pos.distance_squared(p) > 2: + self.brokenBarriers.remove(p) # walked away: let it go + continue + if ct.is_in_vision(p) and ct.can_build_barrier(p): + ct.build_barrier(p) + mi.own_barriers |= mi.bit(p.x, p.y) + mi.note_tile(p.x, p.y, mapinfo_mod.T_BLOCK) + self.brokenBarriers.remove(p) + return + + def _siegeRayTiles(self, core): + """The tiles a gunner could shoot the given 2x2 core from: cardinal + rays off each footprint row/col at distance 2-3 (range^2 13 caps the + ray at 3). Distance-1 ring is left free — ours spawns there, theirs + is where our own ring guns go.""" + mi = self.mi + tiles = [] + for o in (0, 1): + for d in (2, 3): + tiles.append((core.x - d, core.y + o)) # west ray + tiles.append((core.x + 1 + d, core.y + o)) # east ray + tiles.append((core.x + o, core.y - d)) # north ray + tiles.append((core.x + o, core.y + 1 + d)) # south ray + return [(x, y) for (x, y) in tiles if 0 <= x < mi.w and 0 <= y < mi.h] + + def _denyHomeSiege(self, ct, mi): + """Pantheon guard-conveyor idea aimed at the core: pre-occupy the + gunner firing tiles around OUR core with 3-Ti barriers so a siege + has nowhere to stand. Denial beats reaction (kdef gates proved + reacting after the siege stands is too late).""" + if ct.get_action_cooldown() != 0 or self.teamCore is None: + return + if ct.get_global_resources() < ct.get_barrier_cost() + 40: + return + pos = ct.get_position() + for (x, y) in self._siegeRayTiles(self.teamCore): + p = Position(x, y) + if pos.distance_squared(p) > 2 or p == pos: + continue + if not ct.is_in_vision(p): + continue + if ct.get_tile_env(p) in (Environment.WALL, + Environment.ORE_TITANIUM): + continue # walls deny for free; ore should get a harvester + if ct.get_tile_building_id(p) is not None: + continue # any building already denies the tile + if ct.can_build_barrier(p): + ct.build_barrier(p) + mi.own_barriers |= mi.bit(x, y) + mi.note_tile(x, y, mapinfo_mod.T_BLOCK) + return + + def _ringGuns(self, ct, mi): + """v85's proven kill move made deliberate: keep up to 3 gunners ON + the enemy core's adjacent ring. A ring gunner one-clips every spawn + (reload 1) and chips the core — their economy stops either way.""" + if ct.get_action_cooldown() != 0 or self.enemyCore is None: + return + ec = self.enemyCore + pos = ct.get_position() + if pos.distance_squared(ec) > 18: + return + if ct.get_global_resources() < ct.get_gunner_cost() + 40: + return + near = sum(1 for (x, y) in mi.own_turrets + if abs(x - ec.x) + abs(y - ec.y) <= 3) + if near >= 3: + return + foot = {(ec.x + a, ec.y + b) for a in (0, 1) for b in (0, 1)} + ring = [(x, y) for x in range(ec.x - 1, ec.x + 3) + for y in range(ec.y - 1, ec.y + 3) + if (x, y) not in foot + and 0 <= x < mi.w and 0 <= y < mi.h] + for (x, y) in sorted(ring, key=lambda t: (pos.x - t[0]) ** 2 + + (pos.y - t[1]) ** 2): + p = Position(x, y) + if pos.distance_squared(p) > 2 or p == pos: + continue + if not ct.is_in_vision(p): + continue + facing = self._cardinalTo(p, Position(ec.x, ec.y)) + if facing == Direction.CENTRE: + continue + if ct.can_build_gunner(p, facing): + ct.build_gunner(p, facing) + mi.own_turrets[(x, y)] = 'G' + return + + def _tryChokeBlock(self, ct, mi): + if ct.get_action_cooldown() != 0 or not mapanalysis.chokes \ + or self.teamCore is None: + return + pos = ct.get_position() + enemyRef = self._enemyCoreGuess() + ti = ct.get_global_resources() + for choke in mapanalysis.chokes: + tile, clearance = choke[0], choke[1] + p = Position(tile[0], tile[1]) + if pos.distance_squared(p) > 2: + continue + if (enemyRef is not None + and manhattan(p, self.teamCore) >= manhattan(p, enemyRef)): + continue + if not self._inBounds(p) or not ct.is_in_vision(p): + continue + if ct.get_tile_building_id(p) is not None: + continue + if ct.get_tile_env(p) == Environment.ORE_TITANIUM: + continue + if clearance <= 1.2: + if ti >= ct.get_barrier_cost() + 30 and ct.can_build_barrier(p): + ct.build_barrier(p) + return + elif ti >= 100 and ct.can_build_launcher(p): + ct.build_launcher(p) + return + + def _healAdjacent(self, ct): + if ct.get_action_cooldown() != 0: + return + pos = ct.get_position() + if ct.get_hp() < ct.get_max_hp() and ct.can_heal(pos): + ct.heal(pos) + return + # Pantheon 6-tier heal priority: (very damaged?, type tier, damage) + TIER = {EntityType.CORE: 6, EntityType.GUNNER: 5, + EntityType.SENTINEL: 5, EntityType.LAUNCHER: 5, + EntityType.HARVESTER: 4, EntityType.CONVEYOR: 3, + EntityType.SPLITTER: 2, EntityType.BARRIER: 2} + best = None + for bid in ct.get_nearby_buildings(2): + if ct.get_team(bid) != ct.get_team(): + continue + dmg = ct.get_max_hp(bid) - ct.get_hp(bid) + if dmg >= 4: + p = ct.get_position(bid) + key = (1 if dmg >= 12 else 0, + TIER.get(ct.get_entity_type(bid), 1), dmg) + if best is None or key > best[0]: + best = (key, p) + if best is not None and ct.can_heal(best[1]): + ct.heal(best[1]) + + def _advanceAnalysis(self, ct): + job = mapanalysis.job + if job is None and ct.get_cpu_time_elapsed() < mapanalysis.CPU_BUDGET_US: + job = mapanalysis.get_job() + while (job is not None + and ct.get_cpu_time_elapsed() < mapanalysis.CPU_BUDGET_US + and job.phase != mapanalysis.DONE): + job.step() + + # ------------------------------------------------------------------ + # Turrets (ported from botv2 — proven handlers) + # ------------------------------------------------------------------ + def sentinel(self, ct: Controller) -> None: + if self.teamCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) == ct.get_team()): + self.teamCore = ct.get_position(bid) + team = ct.get_team() + seesEnemy = False + for eid in ct.get_nearby_entities(): + if ct.get_team(eid) != team: + seesEnemy = True + break + if seesEnemy: + self.idleTurns = 0 + else: + self.idleTurns += 1 + if self.idleTurns > 40 and ct.get_current_round() > 150: + nearHarv = any( + ct.get_team(b) == team + and ct.get_entity_type(b) == EntityType.HARVESTER + for b in ct.get_nearby_buildings(8)) + nearCore = (self.teamCore is not None + and manhattan(ct.get_position(), + self.teamCore) <= 6) + if not nearHarv and not nearCore: + ct.self_destruct() + return + priority = { + EntityType.CORE: 5, EntityType.SENTINEL: 3, EntityType.GUNNER: 3, + EntityType.BARRIER: 2, EntityType.LAUNCHER: 2, + EntityType.CONVEYOR: 1, + } + targets = set(priority) + bestTile, bestKey = None, None + for tile in ct.get_attackable_tiles(): + if not self._inBounds(tile) or not ct.is_in_vision(tile): + continue + buildingID = ct.get_tile_building_id(tile) + builderID = ct.get_tile_builder_bot_id(tile) + if builderID is not None and ct.get_team(builderID) == team: + continue + if buildingID is not None and ct.get_team(buildingID) == team: + continue + tilePriority, targetHp = 0, 0 + if buildingID is not None: + btype = ct.get_entity_type(buildingID) + if btype not in targets: + continue + tilePriority = priority.get(btype, 1) + targetHp = ct.get_hp(buildingID) + if builderID is not None: + tilePriority += 2 + elif builderID is not None: + continue + key = (tilePriority, 1 if 0 < targetHp <= 18 else 0, -targetHp) + if bestTile is None or key > bestKey: + bestKey, bestTile = key, tile + if bestTile is None: + return + # Pantheon one-tap lock: if the target survives one shot and a + # LOWER-id ally sentinel also covers it, hold fire — lower id acts + # first, so next turn both shots land the same round (36 dmg kills + # anything but a core chunk) before enemy medics can heal. Bounded + # wait so a dead ally can't deadlock us. + targetHp = 0 + bid = ct.get_tile_building_id(bestTile) \ + if ct.is_in_vision(bestTile) else None + if bid is not None: + targetHp = ct.get_hp(bid) + if targetHp > 18 and self.tapWait < 2: + myId = ct.get_id() + myPos = ct.get_position() + for aid in ct.get_nearby_entities(): + if (aid < myId and ct.get_team(aid) == ct.get_team() + and ct.get_entity_type(aid) == EntityType.SENTINEL + and ct.get_position(aid).distance_squared(bestTile) + <= 32): + self.tapWait += 1 + return + self.tapWait = 0 + if ct.can_fire(bestTile): + ct.fire(bestTile) + + def gunner(self, ct: Controller) -> None: + target = ct.get_gunner_target() + if target is not None: + builder_id = ct.get_tile_builder_bot_id(target) + building_id = ct.get_tile_building_id(target) + tid = builder_id if builder_id is not None else building_id + if tid is not None and ct.get_team(tid) != ct.get_team(): + if ct.can_fire(target): + ct.fire(target) + return + if ct.get_global_resources() < 60 or ct.get_action_cooldown() != 0: + return + my_pos = ct.get_position() + current = ct.get_direction() + cands = [] + for eid in ct.get_nearby_entities(13): + et = ct.get_entity_type(eid) + if et not in (EntityType.GUNNER, EntityType.SENTINEL, + EntityType.BUILDER_BOT): + continue + if ct.get_team(eid) == ct.get_team(): + continue + ep = ct.get_position(eid) + dx, dy = ep.x - my_pos.x, ep.y - my_pos.y + if dx != 0 and dy != 0: + continue + if dx > 0: + f = Direction.EAST + elif dx < 0: + f = Direction.WEST + elif dy > 0: + f = Direction.SOUTH + elif dy < 0: + f = Direction.NORTH + else: + continue + if f == current: + continue + pr = 0 if et in (EntityType.GUNNER, EntityType.SENTINEL) else 1 + cands.append((pr, my_pos.distance_squared(ep), + CARDINALS.index(f), f)) + if cands: + f = min(cands)[3] + if ct.can_rotate(f): + ct.rotate(f) + + def launcher(self, ct: Controller) -> None: + myLoc = ct.get_position() + if self.teamCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) == ct.get_team()): + self.teamCore = ct.get_position(bid) + anchor = self.teamCore if self.teamCore is not None else myLoc + team = ct.get_team() + hist = self.enemyBotHistory + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != team + and ct.get_entity_type(uid) == EntityType.BUILDER_BOT): + ep = ct.get_position(uid) + lst = hist.setdefault(uid, []) + if not lst or lst[-1] != ep: + lst.append(ep) + if len(lst) > 12: + lst.pop(0) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx == 0 and dy == 0: + continue + adjacent = Position(myLoc.x + dx, myLoc.y + dy) + if (not self._inBounds(adjacent) + or not ct.is_in_vision(adjacent)): + continue + bot_id = ct.get_tile_builder_bot_id(adjacent) + if bot_id is None or ct.get_team(bot_id) == team: + continue + walked = set(hist.get(bot_id, ())[:-3]) + # Pantheon region-minimizing throw: flood the area the bot + # could navigate from each destination (enemy-POV, unseen = + # impassable) and throw into the SMALLEST pocket. Trail tiles + # (trap: forced retrace) outrank everything. + mi = self.mi + if mi is None: + w, h = ct.get_map_width(), ct.get_map_height() + self.mi = mi = mapinfo_mod.MapInfo(w, h) + mi.update_vision(ct) + epass = mi.seen & ~mi.walls & ~mi.blocked + best, bestKey = None, None + for tile in ct.get_nearby_tiles(26): + if not ct.can_launch(adjacent, tile): + continue + seed = mi.bit(tile.x, tile.y) + region = seed + for _ in range(12): + grown = mi.expand(region) & epass + if grown == region: + break + region = grown + size = bin(region).count('1') + key = (1 if tile in walked else 0, + -size, manhattan(anchor, tile)) + if bestKey is None or key > bestKey: + bestKey, best = key, tile + if best is not None: + ct.launch(adjacent, best) + return + + # ---- misc ---- + def _inBounds(self, p): + mi = self.mi + if mi is None: + return False + return 0 <= p.x < mi.w and 0 <= p.y < mi.h + + @staticmethod + def _cardinalTo(origin, target): + dx = target.x - origin.x + dy = target.y - origin.y + if abs(dx) >= abs(dy) and dx != 0: + return Direction.EAST if dx > 0 else Direction.WEST + if dy != 0: + return Direction.SOUTH if dy > 0 else Direction.NORTH + return Direction.CENTRE diff --git a/bots/spar_barrier_fort/mapanalysis.py b/bots/spar_barrier_fort/mapanalysis.py new file mode 100644 index 0000000..c226589 --- /dev/null +++ b/bots/spar_barrier_fort/mapanalysis.py @@ -0,0 +1,777 @@ +"""chokepoint finder for the shared tile map. + +one team-wide resumable job. builders call note_structural_tile only on +passable<->blocking changes, dupes get coalesced here. results are plain +(x, y) tuples so this file doesnt need fcode. + +smoke test, two rooms + a 1 wide corridor:: + + >>> grid = [[2] * 20 for _ in range(20)] + >>> for x in range(1, 8): + ... for y in range(3, 17): grid[x][y] = 0 + >>> for x in range(12, 19): + ... for y in range(3, 17): grid[x][y] = 0 + >>> for x in range(8, 12): grid[x][10] = 0 + >>> result, _ = analyze_for_test(grid) + >>> len(result) + 1 + >>> result[0][:2] + ((10, 10), 1) + >>> open_map = [[0] * 20 for _ in range(20)] + >>> analyze_for_test(open_map)[0] + [] + +maps are map[x][y] like Player.fullMap. +""" + +from collections import deque + + +REGION_MIN_RADIUS = 3 # min clearance for a big-room region node +MAX_CHOKE_RADIUS = 2 # widest thing we still call a choke +RATIO_SMALL = 0.7 # choke/smaller-room ratio past which rooms just merge +RATIO_LARGE = 0.5 # same but vs the bigger room +RATIO_TWO_CHOKE = 0.8 # collapse 2-exit sliver regions with a wide exit +CPU_BUDGET_US = 6000 # no analysis cpu past this point in a turn +STEP_SLICE = 200 # max queue items per step() slice + +CLEARANCE = 0 +SKELETON = 1 +PRUNE = 2 +REGIONS = 3 +CHOKES = 4 +MERGE = 5 +DONE = 6 + + +struct_version = 0 +chokes = [] +region_partition = [] +region_nodes = [] +job = None +_debug_items = [] +_debug_cursor = 0 + +_map_width = 0 +_map_height = 0 +_structural_map = [] + + +def _index(x, y, height): + return x * height + y + + +def configure_map(width, height): + """Initialise the shared structural map; unexplored tiles are blocking.""" + global _map_width, _map_height, _structural_map, struct_version, job + global chokes, region_partition, region_nodes, _debug_items, _debug_cursor + if width == _map_width and height == _map_height and _structural_map: + return + _map_width = width + _map_height = height + _structural_map = [True] * (width * height) + struct_version += 1 + job = None + chokes = [] + region_partition = [] + region_nodes = [] + _debug_items = [] + _debug_cursor = 0 + + +def reset_map(width, height): + """Start a fresh match even when its dimensions match the previous one.""" + global _map_width, _map_height, _structural_map, struct_version, job + global chokes, region_partition, region_nodes, _debug_items, _debug_cursor + _map_width = width + _map_height = height + _structural_map = [True] * (width * height) + struct_version += 1 + job = None + chokes = [] + region_partition = [] + region_nodes = [] + _debug_items = [] + _debug_cursor = 0 + + +def note_structural_tile(x, y, blocking, width, height): + """Record one structural observation and bump the version only if needed.""" + global struct_version, job, chokes, region_partition, region_nodes + global _debug_items, _debug_cursor + configure_map(width, height) + idx = _index(x, y, height) + blocking = bool(blocking) + if _structural_map[idx] == blocking: + return False + _structural_map[idx] = blocking + struct_version += 1 + job = None + chokes = [] + region_partition = [] + region_nodes = [] + _debug_items = [] + _debug_cursor = 0 + return True + + +def get_job(): + """Return the current-version singleton job, creating it lazily.""" + global job + if not _structural_map: + return None + if job is None or job.version != struct_version: + job = AnalysisJob( + _map_width, _map_height, _structural_map, struct_version + ) + return job + + +def region_at(x, y): + """Return the published region id for a tile, or -1 if none is ready.""" + if (not region_partition or x < 0 or y < 0 + or x >= _map_width or y >= _map_height): + return -1 + return region_partition[_index(x, y, _map_height)] + + +def draw_debug(ct, position_factory, deadline_us=9000): + """Draw the published red chokes and green nodes in bounded spare slices.""" + global _debug_cursor + items = _debug_items + while (_debug_cursor < len(items) + and ct.get_cpu_time_elapsed() < deadline_us): + tile, red, green, blue = items[_debug_cursor] + _debug_cursor += 1 + ct.draw_indicator_dot(position_factory(tile[0], tile[1]), + red, green, blue) + + +class AnalysisJob: + """Small-slice phase machine; callers enforce the per-turn CPU deadline.""" + + def __init__(self, width, height, blocking, version): + self.width = width + self.height = height + self.count = width * height + self.blocking = blocking[:] + self.version = version + self.phase = CLEARANCE + + self.clearance = [-1] * self.count + self.skeleton = [False] * self.count + self.adj = [[] for _ in range(self.count)] + self.active = [False] * self.count + self.degree = [0] * self.count + self.raw_region_nodes = [] + self.region_index = [-1] * self.count + self.candidates = [] + + self._mode = "seed" + self._cursor = 0 + self._queue = deque() + self._candidate = -1 + self._candidate_scan = 0 + self._candidate_end = 0 + self._candidate_greater = False + self._region_cursor = 0 + self._neighbor_cursor = 0 + self._edge_seen = set() + self._walk = None + + self.parent = [] + self.root_clearance = [] + self.root_size = [] + self.keep = [] + self.partition = [] + self._partition_clearance = [] + self._partition_size = [] + self._merge_cursor = 0 + self._ratio_buckets = [] + self._ratio_bucket = -1 + self._ratio_bucket_pos = 0 + self._incidence = [] + self._merge_changed = False + self._output = [] + + def step(self): + if self.phase == CLEARANCE: + self._step_clearance() + elif self.phase == SKELETON: + self._step_skeleton() + elif self.phase == PRUNE: + self._step_prune() + elif self.phase == REGIONS: + self._step_regions() + elif self.phase == CHOKES: + self._step_chokes() + elif self.phase == MERGE: + self._step_merge() + + def _neighbors(self, idx): + h = self.height + w = self.width + x, y = divmod(idx, h) + if x: + yield idx - h + if x + 1 < w: + yield idx + h + if y: + yield idx - 1 + if y + 1 < h: + yield idx + 1 + + def _step_clearance(self): + limit = STEP_SLICE + n = self.count + if self._mode == "seed": + end = min(n, self._cursor + limit) + for idx in range(self._cursor, end): + if self.blocking[idx]: + self.clearance[idx] = 0 + self._queue.append(idx) + self._cursor = end + if end == n: + self._mode = "bfs" if self._queue else "unbounded" + self._cursor = 0 + return + + if self._mode == "unbounded": + end = min(n, self._cursor + limit) + far = self.width + self.height + 1 + for idx in range(self._cursor, end): + if not self.blocking[idx]: + self.clearance[idx] = far + self._cursor = end + if end == n: + self.phase = SKELETON + self._mode = "mark" + self._cursor = 0 + return + + pops = 0 + clearance = self.clearance + queue = self._queue + while queue and pops < limit: + idx = queue.popleft() + next_distance = clearance[idx] + 1 + for other in self._neighbors(idx): + if clearance[other] < 0: + clearance[other] = next_distance + queue.append(other) + pops += 1 + if not queue: + self.phase = SKELETON + self._mode = "mark" + self._cursor = 0 + + def _step_skeleton(self): + n = self.count + end = min(n, self._cursor + STEP_SLICE) + if self._mode == "mark": + clearance = self.clearance + blocking = self.blocking + skeleton = self.skeleton + for idx in range(self._cursor, end): + if blocking[idx]: + continue + value = clearance[idx] + is_maximum = True + for other in self._neighbors(idx): + if clearance[other] > value: + is_maximum = False + break + skeleton[idx] = is_maximum + self._cursor = end + if end == n: + self._mode = "connect" + self._cursor = 0 + return + + skeleton = self.skeleton + adj = self.adj + h = self.height + w = self.width + for idx in range(self._cursor, end): + if not skeleton[idx]: + continue + x, y = divmod(idx, h) + if x + 1 < w and skeleton[idx + h]: + adj[idx].append(idx + h) + adj[idx + h].append(idx) + if y + 1 < h and skeleton[idx + 1]: + adj[idx].append(idx + 1) + adj[idx + 1].append(idx) + self._cursor = end + if end == n: + self.phase = PRUNE + self._mode = "init" + self._cursor = 0 + + def _step_prune(self): + if self._mode == "init": + end = min(self.count, self._cursor + STEP_SLICE) + for idx in range(self._cursor, end): + if self.skeleton[idx]: + self.active[idx] = True + degree = len(self.adj[idx]) + self.degree[idx] = degree + if degree == 1: + self._queue.append(idx) + self._cursor = end + if end == self.count: + self._mode = "peel" + return + + pops = 0 + while self._queue and pops < STEP_SLICE: + idx = self._queue.popleft() + pops += 1 + if not self.active[idx] or self.degree[idx] != 1: + continue + neighbor = -1 + for other in self.adj[idx]: + if self.active[other]: + neighbor = other + break + if neighbor < 0: + continue + if self.clearance[idx] < self.clearance[neighbor]: + self.active[idx] = False + self.degree[idx] = 0 + self.degree[neighbor] -= 1 + if self.degree[neighbor] == 1: + self._queue.append(neighbor) + if not self._queue: + self.phase = REGIONS + self._mode = "scan" + self._cursor = 0 + + def _finish_region_candidate(self): + if not self._candidate_greater: + idx = self._candidate + self.region_index[idx] = len(self.raw_region_nodes) + self.raw_region_nodes.append(idx) + self._candidate = -1 + + def _step_regions(self): + ops = 0 + h = self.height + w = self.width + while ops < STEP_SLICE: + if self._candidate >= 0: + idx = self._candidate + x, y = divmod(idx, h) + radius = self.clearance[idx] + while self._candidate_scan < self._candidate_end and ops < STEP_SLICE: + pos = self._candidate_scan + self._candidate_scan += 1 + ops += 1 + ox, oy = divmod(pos, h) + if (abs(ox - x) <= radius and abs(oy - y) <= radius + and self.active[pos] + and self.clearance[pos] > radius): + self._candidate_greater = True + self._candidate_scan = self._candidate_end + if self._candidate_scan >= self._candidate_end: + self._finish_region_candidate() + continue + + if self._cursor >= self.count: + self.phase = CHOKES + self._mode = "walk" + self._region_cursor = 0 + self._neighbor_cursor = 0 + return + + idx = self._cursor + self._cursor += 1 + ops += 1 + if not self.active[idx]: + continue + degree = self.degree[idx] + if degree != 2: + self.region_index[idx] = len(self.raw_region_nodes) + self.raw_region_nodes.append(idx) + elif self.clearance[idx] >= REGION_MIN_RADIUS: + radius = self.clearance[idx] + x, y = divmod(idx, h) + x0 = max(0, x - radius) + x1 = min(w - 1, x + radius) + y0 = max(0, y - radius) + y1 = min(h - 1, y + radius) + # scan the enclosing flat range; Chebyshev bounds are checked above + self._candidate = idx + self._candidate_scan = x0 * h + y0 + self._candidate_end = x1 * h + y1 + 1 + self._candidate_greater = False + + def _edge_key(self, left, right): + if left > right: + left, right = right, left + return left * self.count + right + + def _outside_seed(self, endpoint, inward): + """Step off a local-max plateau toward its adjacent open region.""" + best = endpoint + best_clearance = -1 + for other in self._neighbors(endpoint): + if other == inward or self.blocking[other]: + continue + value = self.clearance[other] + if value > best_clearance: + best = other + best_clearance = value + return best + + def _begin_next_walk(self): + nodes = self.raw_region_nodes + while self._region_cursor < len(nodes): + start = nodes[self._region_cursor] + neighbors = self.adj[start] + while self._neighbor_cursor < len(neighbors): + other = neighbors[self._neighbor_cursor] + self._neighbor_cursor += 1 + if not self.active[other]: + continue + key = self._edge_key(start, other) + if key in self._edge_seen: + continue + self._edge_seen.add(key) + start_region = self.region_index[start] + values = [(self.clearance[start], start), + (self.clearance[other], other)] + minimum = min(values)[0] + minima = [tile for value, tile in values if value == minimum] + self._walk = [start_region, start, other, minimum, minima, + [start, other]] + return True + self._region_cursor += 1 + self._neighbor_cursor = 0 + return False + + def _step_chokes(self): + ops = 0 + while ops < STEP_SLICE: + if self._walk is None: + if not self._begin_next_walk(): + self.phase = MERGE + self._mode = "partition_raw_init" + return + start_region, previous, current, minimum, minima, path = self._walk + ops += 1 + current_region = self.region_index[current] + if current_region >= 0 and current_region != start_region: + if minimum <= MAX_CHOKE_RADIUS: + # prefer the middle of the minimum-clearance run + chosen = minima[len(minima) // 2] + left_pos = self._outside_seed(path[0], path[1]) + right_pos = self._outside_seed(path[-1], path[-2]) + self.candidates.append( + [minimum, chosen, start_region, current_region, + left_pos, right_pos] + ) + self._walk = None + continue + + next_tile = -1 + for other in self.adj[current]: + if self.active[other] and other != previous: + next_tile = other + break + if next_tile < 0: + self._walk = None + continue + key = self._edge_key(current, next_tile) + if key in self._edge_seen: + self._walk = None + continue + self._edge_seen.add(key) + value = self.clearance[next_tile] + if value < minimum: + minimum = value + minima = [next_tile] + elif value == minimum: + minima.append(next_tile) + path.append(next_tile) + self._walk = [start_region, current, next_tile, minimum, minima, path] + + def _start_partition(self, removed): + self.partition = [-1] * self.count + self._removed = removed + self._partition_cursor = 0 + self._component = -1 + self._queue.clear() + self._partition_clearance = [] + self._partition_size = [] + + def _step_partition(self, next_mode): + ops = 0 + while ops < STEP_SLICE: + if self._queue: + idx = self._queue.popleft() + ops += 1 + self._partition_size[self._component] += 1 + value = self.clearance[idx] + if value > self._partition_clearance[self._component]: + self._partition_clearance[self._component] = value + for other in self._neighbors(idx): + if (self.partition[other] < 0 and not self.blocking[other] + and other not in self._removed): + self.partition[other] = self._component + self._queue.append(other) + continue + while self._partition_cursor < self.count: + idx = self._partition_cursor + self._partition_cursor += 1 + ops += 1 + if (not self.blocking[idx] and idx not in self._removed + and self.partition[idx] < 0): + self._component += 1 + self._partition_clearance.append(0) + self._partition_size.append(0) + self.partition[idx] = self._component + self._queue.append(idx) + break + if ops >= STEP_SLICE: + return False + if self._partition_cursor >= self.count and not self._queue: + self._mode = next_mode + return True + return False + + def _find(self, node): + parent = self.parent + while parent[node] != node: + parent[node] = parent[parent[node]] + node = parent[node] + return node + + def _union(self, left, right): + left = self._find(left) + right = self._find(right) + if left == right: + return left + lc = self.root_clearance[left] + rc = self.root_clearance[right] + if rc > lc or (rc == lc and right < left): + left, right = right, left + self.parent[right] = left + self.root_clearance[left] = max(lc, rc) + self.root_size[left] += self.root_size[right] + return left + + def _candidate_components(self, candidate): + left = self.partition[candidate[4]] + right = self.partition[candidate[5]] + if left < 0: + left = self.partition[self.raw_region_nodes[candidate[2]]] + if right < 0: + right = self.partition[self.raw_region_nodes[candidate[3]]] + return left, right + + def _step_merge(self): + if self._mode == "partition_raw_init": + self._removed = set() + self._merge_cursor = 0 + self._mode = "partition_raw_remove" + return + if self._mode == "partition_raw_remove": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + self._removed.add(self.candidates[idx][1]) + self._merge_cursor = end + if end == len(self.candidates): + self._start_partition(self._removed) + self._mode = "partition_raw" + return + if self._mode == "partition_raw": + self._step_partition("ratio_init") + return + if self._mode == "ratio_init": + count = len(self._partition_size) + self.parent = list(range(count)) + self.root_clearance = self._partition_clearance[:] + self.root_size = self._partition_size[:] + self.keep = [False] * len(self.candidates) + self._ratio_buckets = [ + [] for _ in range(MAX_CHOKE_RADIUS + 1) + ] + self._merge_cursor = 0 + self._mode = "ratio_order" + return + if self._mode == "ratio_order": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + clearance = self.candidates[idx][0] + self._ratio_buckets[clearance].append(idx) + self._merge_cursor = end + if end == len(self.candidates): + self._ratio_bucket = MAX_CHOKE_RADIUS + self._ratio_bucket_pos = 0 + self._mode = "ratio" + return + if self._mode == "ratio": + ops = 0 + while self._ratio_bucket >= 0 and ops < STEP_SLICE: + bucket = self._ratio_buckets[self._ratio_bucket] + if self._ratio_bucket_pos >= len(bucket): + self._ratio_bucket -= 1 + self._ratio_bucket_pos = 0 + continue + candidate_index = bucket[self._ratio_bucket_pos] + self._ratio_bucket_pos += 1 + ops += 1 + candidate = self.candidates[candidate_index] + left, right = self._candidate_components(candidate) + if left < 0 or right < 0: + continue + left = self._find(left) + right = self._find(right) + if left == right: + continue + choke_clearance = candidate[0] + small = min(self.root_clearance[left], self.root_clearance[right]) + large = max(self.root_clearance[left], self.root_clearance[right]) + if (choke_clearance > RATIO_SMALL * small + or choke_clearance > RATIO_LARGE * large): + self._union(left, right) + else: + self.keep[candidate_index] = True + if self._ratio_bucket < 0: + self._mode = "incidence_init" + return + if self._mode == "incidence_init": + self._incidence = [[] for _ in self.parent] + self._merge_cursor = 0 + self._mode = "incidence" + return + if self._mode == "incidence": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + if not self.keep[idx]: + continue + left, right = self._candidate_components(self.candidates[idx]) + if left < 0 or right < 0: + self.keep[idx] = False + continue + left = self._find(left) + right = self._find(right) + if left == right: + self.keep[idx] = False + continue + self._incidence[left].append(idx) + self._incidence[right].append(idx) + self._merge_cursor = end + if end == len(self.candidates): + self._merge_cursor = 0 + self._merge_changed = False + self._mode = "two_choke" + return + if self._mode == "two_choke": + end = min(len(self.parent), self._merge_cursor + STEP_SLICE) + for region in range(self._merge_cursor, end): + if self._find(region) != region: + continue + exits = self._incidence[region] + if len(exits) != 2: + continue + wider = max(exits, key=lambda i: self.candidates[i][0]) + if (self.candidates[wider][0] + > RATIO_TWO_CHOKE * self.root_clearance[region]): + left, right = self._candidate_components(self.candidates[wider]) + self._union(left, right) + self.keep[wider] = False + self._merge_changed = True + break + self._merge_cursor = end + if self._merge_changed: + self._mode = "incidence_init" + elif end == len(self.parent): + self._mode = "final_partition_init" + return + if self._mode == "final_partition_init": + self._removed = set() + self._merge_cursor = 0 + self._mode = "final_partition_remove" + return + if self._mode == "final_partition_remove": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + if self.keep[idx]: + self._removed.add(self.candidates[idx][1]) + self._merge_cursor = end + if end == len(self.candidates): + self._start_partition(self._removed) + self._mode = "final_partition" + return + if self._mode == "final_partition": + self._step_partition("publish_init") + return + if self._mode == "publish_init": + self._output = [] + self._merge_cursor = 0 + self._mode = "publish" + return + if self._mode == "publish": + self._step_publish() + return + if self._mode == "debug_publish": + self._step_debug_publish() + + def _step_publish(self): + global chokes, region_partition, region_nodes + global _debug_items, _debug_cursor + h = self.height + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + candidate = self.candidates[idx] + if not self.keep[idx]: + continue + left = self.partition[candidate[4]] + right = self.partition[candidate[5]] + if left < 0 or right < 0 or left == right: + continue + tile = divmod(candidate[1], h) + self._output.append((tile, candidate[0], + self._partition_size[left], + self._partition_size[right])) + self._merge_cursor = end + if end == len(self.candidates): + chokes = self._output[:] + region_partition = self.partition[:] + region_nodes = [] + _debug_items = [(choke[0], 255, 0, 0) for choke in chokes] + _debug_cursor = 0 + self._merge_cursor = 0 + self._mode = "debug_publish" + + def _step_debug_publish(self): + global region_nodes, _debug_items + end = min(len(self.raw_region_nodes), + self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + node = divmod(self.raw_region_nodes[idx], self.height) + region_nodes.append(node) + _debug_items.append((node, 0, 255, 0)) + self._merge_cursor = end + if end == len(self.raw_region_nodes): + self.phase = DONE + + +def analyze_for_test(full_map): + """Synchronously analyze an ``[x][y]`` synthetic map for doctests/tests.""" + if not full_map or not full_map[0]: + return [], [] + width = len(full_map) + height = len(full_map[0]) + blocking = [] + for x in range(width): + if len(full_map[x]) != height: + raise ValueError("full_map must be rectangular and indexed [x][y]") + for y in range(height): + blocking.append(full_map[x][y] in (-1, 2, 3)) + test_job = AnalysisJob(width, height, blocking, 0) + while test_job.phase != DONE: + test_job.step() + return chokes[:], region_partition[:] diff --git a/bots/spar_barrier_fort/mapinfo.py b/bots/spar_barrier_fort/mapinfo.py new file mode 100644 index 0000000..66dc0e5 --- /dev/null +++ b/bots/spar_barrier_fort/mapinfo.py @@ -0,0 +1,301 @@ +"""Bitmask map-info layer (Pantheon/Khaos rebuild). + +The whole map lives in Python big-int masks: bit n = x + y*W. Geometric +neighbour expansion is 4 shifts; derived masks (threat, placeable, frontier) +are boolean algebra over whole boards. On <=30x30 maps a board is <=900 bits. + +Static knowledge (seen/walls/ore) accumulates and is shared through the comm +store with the same 12-bit tile protocol botv2 uses (slots 1..6, 16 tiles per +broadcast, rotation keyed on slot 0's spawn counter). + +>>> m = MapInfo(4, 3) +>>> b = m.bit(1, 1) +>>> sorted(m.xy(t) for t in m.iter_bits(m.expand(b) & ~b)) +[(0, 1), (1, 0), (1, 2), (2, 1)] +>>> m.xy(m.east(b)) +(2, 1) +>>> m.east(m.bit(3, 1)) # east edge does not wrap +0 +""" + +from fcode import Direction, EntityType, Environment, Position + +DIR_DELTA = { + Direction.NORTH: (0, -1), Direction.SOUTH: (0, 1), + Direction.EAST: (1, 0), Direction.WEST: (-1, 0), +} + +# tile type codes shared through the store (identical to botv2) +T_EMPTY, T_ORE, T_WALL, T_BLOCK = 0, 1, 2, 3 + +GUNNER_RAY = 3 # threat approximation: gunner cross length +SENT_BAND = 5 # sentinel band length (3 wide) + + +class MapInfo: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.n = w * h + self.board = (1 << self.n) - 1 + col0 = 0 + for y in range(h): + col0 |= 1 << (y * w) + self.not_col0 = self.board & ~col0 + self.not_colL = self.board & ~(col0 << (w - 1)) + + # accumulated static knowledge + self.seen = 0 + self.walls = 0 + self.ore = 0 + self.blocked = 0 # non-walkable buildings (shared, any team) + self.struct_version = 0 + + # remembered point features (local to this unit, refreshed on sight) + self.enemy_turrets = {} # (x,y) -> 'G' | 'S' + self.enemy_buildings = {} # (x,y) -> EntityType + self.own_harvesters = {} # (x,y) -> True + self.own_turrets = {} # (x,y) -> True (death detection) + self.turret_losses = [] # tiles where an own turret just died + self._threat_cache = (None, 0) + + # per-turn dynamic masks + self.own_bots = 0 + self.enemy_bots = 0 + self.own_conveyors = 0 + self.own_barriers = 0 # break-walkable at cost (Pantheon 15) + self.own_loaded = 0 # own conveyors holding a stack (jam-avoid) + # remembered facings of own conveyors: (x,y) -> (dx,dy) output delta + self.own_conv_facing = {} + + # outgoing share queue: list of (x, y, code) + self.out_tiles = [] + # optional hook: called (x, y, blocking) on structural changes so the + # chokepoint analyzer can invalidate incrementally + self.on_block_change = None + + # ---- geometry ---- + def bit(self, x: int, y: int) -> int: + return 1 << (x + y * self.w) + + def xy(self, single_bit: int): + n = single_bit.bit_length() - 1 + return n % self.w, n // self.w + + def east(self, m): + return (m << 1) & self.not_col0 + + def west(self, m): + return (m >> 1) & self.not_colL + + def south(self, m): + return (m << self.w) & self.board + + def north(self, m): + return m >> self.w + + def expand(self, m): + return m | self.east(m) | self.west(m) | self.south(m) | self.north(m) + + def iter_bits(self, m): + while m: + lsb = m & -m + yield lsb + m ^= lsb + + # ---- derived masks ---- + def passable(self): + """Tiles a builder could stand on (unknown treated optimistically).""" + return self.board & ~self.walls & ~self.blocked + + def threat(self): + """Soft-threat mask from remembered enemy turrets (facing unknown: + gunner = cardinal cross len 3, sentinel = 3-wide bands len 5).""" + key = tuple(sorted(self.enemy_turrets.items())) + cached, ver = self._threat_cache + if cached is not None and ver == key: + return cached + t = 0 + shifts = (self.east, self.west, self.north, self.south) + for (x, y), kind in self.enemy_turrets.items(): + b = self.bit(x, y) + if kind == 'G': + for shift in shifts: + ray = b + for _ in range(GUNNER_RAY): + ray = shift(ray) & ~self.walls + t |= ray + else: + t |= self.expand(b) + for shift in shifts: + ray = b + acc = 0 + for _ in range(SENT_BAND): + ray = shift(ray) & ~self.walls + acc |= ray + t |= self.expand(acc) # widen the ray into a 3-wide band + t &= self.board + self._threat_cache = (t, key) + return t + + def frontier(self): + """Seen tiles adjacent to unseen ones — exploration targets.""" + unseen = self.board & ~self.seen + return self.expand(unseen) & self.seen & ~self.walls + + # ---- knowledge updates ---- + def note_tile(self, x, y, code, share=True): + b = self.bit(x, y) + old_wall = self.walls & b + old_block = self.blocked & b + newly = not (self.seen & b) + self.seen |= b + if code == T_WALL: + self.walls |= b + elif code == T_BLOCK: + self.blocked |= b + else: + changed = old_block != 0 + self.blocked &= ~b + if code == T_ORE: + self.ore |= b + if changed: + self.struct_version += 1 + if self.on_block_change: + self.on_block_change(x, y, False) + if (code in (T_WALL, T_BLOCK)) and not (old_wall or old_block): + self.struct_version += 1 + if self.on_block_change: + self.on_block_change(x, y, True) + if share and (newly or (code == T_BLOCK) != bool(old_block)): + self.out_tiles.append((x, y, code)) + if len(self.out_tiles) > 96: + del self.out_tiles[:32] + + def update_vision(self, ct): + """Per-turn scan: static tiles into masks, dynamic entities rebuilt. + + Vision-delta optimization (Pantheon update_move): env is immutable, so + for already-seen tiles we skip get_tile_env entirely — walls skip ALL + queries (nothing can stand on them), other seen tiles derive env from + the ore mask and only pay the building-id query.""" + my_team = ct.get_team() + get_env = ct.get_tile_env + get_bid = ct.get_tile_building_id + note = self.note_tile + self.own_bots = 0 + self.enemy_bots = 0 + self.own_conveyors = 0 + self.own_barriers = 0 + self.own_loaded = 0 # own conveyors currently holding a stack + get_stored = ct.get_stored_resource + WALKABLE = (EntityType.CONVEYOR, EntityType.SPLITTER) + seen = self.seen + walls = self.walls + ore = self.ore + for tile in ct.get_nearby_tiles(): + x, y = tile.x, tile.y + b = 1 << (x + y * self.w) + if seen & b: + if walls & b: + continue # immutable, uninhabitable: zero queries + env = (Environment.ORE_TITANIUM if ore & b + else Environment.EMPTY) + else: + env = get_env(tile) + if env == Environment.WALL: + note(x, y, T_WALL) + continue + bid = get_bid(tile) + if bid is None: + note(x, y, T_ORE if env == Environment.ORE_TITANIUM else T_EMPTY) + self.enemy_buildings.pop((x, y), None) + self.enemy_turrets.pop((x, y), None) + self.own_harvesters.pop((x, y), None) + if self.own_turrets.pop((x, y), None): + self.turret_losses.append((x, y)) + self.own_conv_facing.pop((x, y), None) + continue + etype = ct.get_entity_type(bid) + team = ct.get_team(bid) + if etype in WALKABLE: + note(x, y, T_ORE if env == Environment.ORE_TITANIUM else T_EMPTY) + if team == my_team: + self.own_conveyors |= self.bit(x, y) + if get_stored(bid) is not None: + self.own_loaded |= self.bit(x, y) + if etype == EntityType.CONVEYOR: + d = DIR_DELTA.get(ct.get_direction(bid)) + if d is not None: + self.own_conv_facing[(x, y)] = d + else: + self.enemy_buildings[(x, y)] = etype + continue + note(x, y, T_BLOCK) + if team != my_team: + self.enemy_buildings[(x, y)] = etype + if etype == EntityType.GUNNER: + self.enemy_turrets[(x, y)] = 'G' + elif etype == EntityType.SENTINEL: + self.enemy_turrets[(x, y)] = 'S' + else: + self.enemy_buildings.pop((x, y), None) + if etype == EntityType.HARVESTER: + self.own_harvesters[(x, y)] = True + elif etype == EntityType.BARRIER: + self.own_barriers |= self.bit(x, y) + elif etype == EntityType.SENTINEL: + self.own_turrets[(x, y)] = 'S' + elif etype in (EntityType.GUNNER, EntityType.LAUNCHER): + self.own_turrets[(x, y)] = True + for uid in ct.get_nearby_units(): + if ct.get_entity_type(uid) != EntityType.BUILDER_BOT: + continue + p = ct.get_position(uid) + if ct.get_team(uid) == my_team: + self.own_bots |= self.bit(p.x, p.y) + else: + self.enemy_bots |= self.bit(p.x, p.y) + + # ---- comm store share (botv2 12-bit protocol, slots 1..6) ---- + def share(self, ct, my_num: int, core: Position): + spawn_count = ct.read_store(0) + if spawn_count <= 0 or my_num < 1 or core is None: + return + if ct.get_current_round() % spawn_count + 1 != my_num: + return + self.out_tiles.sort( + key=lambda t: abs(t[0] - core.x) + abs(t[1] - core.y), + reverse=True) + combined = 0 + for i in range(16): + if i < len(self.out_tiles): + x, y, code = self.out_tiles[i] + val = ((x & 0x1F) << 7) | ((y & 0x1F) << 2) | (code & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val + for i in range(6): + ct.write_store(6 - i, (combined >> (32 * i)) & 0xFFFFFFFF) + del self.out_tiles[:16] + + def absorb(self, ct): + combined = 0 + for i in range(1, 7): + combined = (combined << 32) | ct.read_store(i) + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + if val == 0xFFF: + continue + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + if x >= self.w or y >= self.h: + continue + self.note_tile(x, y, val & 0x3, share=False) + self.out_tiles = [t for t in self.out_tiles + if not (t[0] == x and t[1] == y)] + + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/bots/spar_barrier_fort/path.py b/bots/spar_barrier_fort/path.py new file mode 100644 index 0000000..7ed41d3 --- /dev/null +++ b/bots/spar_barrier_fort/path.py @@ -0,0 +1,173 @@ +"""Bitmask pathfinding (Pantheon/Khaos rebuild). + +All searches operate on MapInfo big-int masks. The Dial's variant runs in +REVERSE from a target mask with a circular array of frontier bitmasks as the +bucket queue (Khaos figure 12): plain step cost 1, stepping into soft threat ++THREAT, so paths detour around enemy turret envelopes unless forced. + +dist_field() unrolls the same search into a per-tile int list for gradient +consumers (conveyor routing). +""" + +THREAT_EXTRA = 12 # entering a threat tile costs 1 + THREAT_EXTRA +BARRIER_EXTRA = 14 # entering an OWN barrier costs 1 + 14 (Pantheon 15: + # the mover destroys it, spending its action) +RING = THREAT_EXTRA + BARRIER_EXTRA + 3 # ring must exceed max edge cost + + +def next_step(mi, start_xy, targets_mask, avoid_mask=0, max_iters=None): + """Reverse Dial's from targets; returns (dx, dy) for the best first step + from start, or None if unreachable. avoid_mask tiles are impassable. + Own barriers are break-walkable at +BARRIER_EXTRA.""" + barriers = mi.own_barriers & ~avoid_mask + passable = (mi.passable() | barriers) & ~avoid_mask + targets = targets_mask & mi.board + if not targets: + return None + threat = mi.threat() + sx, sy = start_xy + sbit = mi.bit(sx, sy) + nbrs = [] + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = sx + dx, sy + dy + if 0 <= nx < mi.w and 0 <= ny < mi.h: + nbrs.append((mi.bit(nx, ny), dx, dy)) + nbr_all = 0 + for b, _, _ in nbrs: + nbr_all |= b + # the start tile itself counts as reachable ground for the wavefront + passable |= sbit | targets + + ring = [0] * RING + ring[0] = targets + visited = 0 + found = {} + if max_iters is None: + max_iters = (mi.w + mi.h) * (THREAT_EXTRA + 1) + 5 + for dist in range(max_iters): + cur = ring[dist % RING] & ~visited + ring[dist % RING] = 0 + if not cur: + if not any(ring): + break + continue + visited |= cur + hit = cur & nbr_all + if hit: + for b, dx, dy in nbrs: + if (hit & b) and b not in found: + found[b] = dist + if len(found) == len(nbrs): + break + nxt = mi.expand(cur) & passable & ~visited + plain = nxt & ~threat & ~barriers + hot = nxt & threat & ~barriers + barr = nxt & barriers & ~threat + both = nxt & barriers & threat + if plain: + ring[(dist + 1) % RING] |= plain + if hot: + ring[(dist + 1 + THREAT_EXTRA) % RING] |= hot + if barr: + ring[(dist + 1 + BARRIER_EXTRA) % RING] |= barr + if both: + ring[(dist + 1 + THREAT_EXTRA + BARRIER_EXTRA) % RING] |= both + if not found: + return None + best = min(found.items(), key=lambda kv: kv[1])[0] + for b, dx, dy in nbrs: + if b == best: + return (dx, dy) + return None + + +def dist_field(mi, targets_mask, avoid_mask=0): + """Full-map cost-to-target list indexed [x + y*w]; 4096 = unreachable.""" + barriers = mi.own_barriers & ~avoid_mask + passable = (mi.passable() | barriers + | (targets_mask & mi.board)) & ~avoid_mask + threat = mi.threat() + field = [4096] * mi.n + ring = [0] * RING + ring[0] = targets_mask & mi.board + visited = 0 + max_iters = (mi.w + mi.h) * (THREAT_EXTRA + 1) + 5 + for dist in range(max_iters): + cur = ring[dist % RING] & ~visited + ring[dist % RING] = 0 + if not cur: + if not any(ring): + break + continue + visited |= cur + m = cur + while m: + lsb = m & -m + field[lsb.bit_length() - 1] = dist + m ^= lsb + nxt = mi.expand(cur) & passable & ~visited + plain = nxt & ~threat & ~barriers + hot = nxt & threat & ~barriers + barr = nxt & barriers & ~threat + both = nxt & barriers & threat + if plain: + ring[(dist + 1) % RING] |= plain + if hot: + ring[(dist + 1 + THREAT_EXTRA) % RING] |= hot + if barr: + ring[(dist + 1 + BARRIER_EXTRA) % RING] |= barr + if both: + ring[(dist + 1 + THREAT_EXTRA + BARRIER_EXTRA) % RING] |= both + return field + + +def closest(mi, start_mask, candidates_mask, avoid_mask=0, max_steps=None): + """Unweighted BFS over passable tiles; returns the candidate bit reached + first (lowest bit as tiebreak) or 0.""" + passable = mi.passable() & ~avoid_mask + front = start_mask & mi.board + visited = front + cands = candidates_mask & mi.board + if front & cands: + hit = front & cands + return hit & -hit + if max_steps is None: + max_steps = mi.w + mi.h + for _ in range(max_steps): + front = mi.expand(front) & (passable | cands) & ~visited + if not front: + return 0 + visited |= front + hit = front & cands + if hit: + return hit & -hit + return 0 + + +def claims(mi, my_bit, others_mask, candidates_mask, max_steps=None): + """Khaos claim partition: simultaneous competing floodfills. Returns the + subset of candidates MY region reaches strictly first (ties -> others, + keeping the claim conservative).""" + passable = mi.passable() + cands = candidates_mask & mi.board + if not cands: + return 0 + my_front = my_bit + other_front = others_mask & mi.board + claimed = my_front | other_front + mine = my_front & cands + cands &= ~other_front + if max_steps is None: + max_steps = mi.w + mi.h + remaining = cands & ~mine + for _ in range(max_steps): + if not remaining or not (my_front or other_front): + break + other_front = mi.expand(other_front) & (passable | cands) & ~claimed + claimed |= other_front + remaining &= ~other_front + my_front = mi.expand(my_front) & (passable | cands) & ~claimed + claimed |= my_front + mine |= my_front & remaining + remaining &= ~my_front + return mine diff --git a/bots/spar_barrier_fort/planes.py b/bots/spar_barrier_fort/planes.py new file mode 100644 index 0000000..27c280b --- /dev/null +++ b/bots/spar_barrier_fort/planes.py @@ -0,0 +1,99 @@ +"""Bit-sliced score planes (Pantheon figure 14) for gunner placement. + +A plane stack is a list of NUM_PLANES big-int bitmasks; the integer score of +tile n is sum(((planes[i] >> n) & 1) << i). add_const() adds a constant to +every tile of a mask in O(NUM_PLANES) whole-board ops — cost independent of +how many tiles are in the mask. argmax_mask() extracts the best-scoring tiles +in one MSB-down pass. + +Scoring: for each facing, shift every enemy-class mask BACKWARD along the +facing k=1..3 steps with wall clipping; tiles that survive are placements +whose forward ray hits that enemy at range k. Score is discounted with range +(gunners can't shoot over buildings, so adjacency is king). +""" + +NUM_PLANES = 8 +MAX_SCORE = (1 << NUM_PLANES) - 1 + +# enemy class -> base placement value (hit at range 1) +# range discount ~0.9^k like Pantheon +RAY_STEPS = 3 +DISCOUNT = (1.0, 0.9, 0.81) + + +def add_const(planes, c, mask): + """Add constant c to every tile in mask (ripple-carry over planes).""" + if not mask or c <= 0: + return + i = 0 + while c and i < NUM_PLANES: + if c & 1: + carry = planes[i] & mask + planes[i] ^= mask + j = i + 1 + while carry and j < NUM_PLANES: + new_carry = planes[j] & carry + planes[j] ^= carry + carry = new_carry + j += 1 + c >>= 1 + i += 1 + + +def argmax_mask(planes, cands): + """(best_score, mask_of_best_tiles) among cands; (0, 0) if none.""" + if not cands: + return 0, 0 + best = 0 + cur = cands + for i in range(NUM_PLANES - 1, -1, -1): + t = cur & planes[i] + if t: + cur = t + best |= 1 << i + return best, cur + + +def gunner_planes(mi, class_masks, valid_mask): + """Per-facing plane stacks for gunner placements. + + class_masks: list of (value, enemy_mask). Returns {facing_key: planes} + with facing_key in 'E','W','N','S' — the direction the built gunner faces. + A placement facing E hits targets to its EAST, so target masks are shifted + WEST (backward) onto placement tiles. + """ + shifts_back = { + 'E': mi.west, 'W': mi.east, 'N': mi.south, 'S': mi.north, + } + out = {} + nw = ~mi.walls + for fk, back in shifts_back.items(): + planes = [0] * NUM_PLANES + for value, emask in class_masks: + if not emask: + continue + s = emask + for k in range(RAY_STEPS): + s = back(s) & nw + if not s: + break + v = int(value * DISCOUNT[k]) + if v <= 0: + continue + add_const(planes, v, s & valid_mask) + out[fk] = planes + return out + + +def best_placement(mi, class_masks, valid_mask): + """(score, x, y, facing_key) of the best gunner placement, or None.""" + stacks = gunner_planes(mi, class_masks, valid_mask) + best = None + for fk, planes in stacks.items(): + score, mask = argmax_mask(planes, valid_mask) + if score > 0 and mask: + lsb = mask & -mask + x, y = mi.xy(lsb) + if best is None or score > best[0]: + best = (score, x, y, fk) + return best diff --git a/bots/spar_barrier_fort/symmetry.py b/bots/spar_barrier_fort/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/spar_barrier_fort/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out From 93273e1d09d4dc8decb633a06c1d955d4417cc01 Mon Sep 17 00:00:00 2001 From: Subodh Date: Sun, 2 Aug 2026 17:33:02 +0200 Subject: [PATCH 15/30] push everything: tests, docs, analysis tooling, evidence and artifacts Everything else from the session and the wider project that had never been committed: the tests/ suite, conveyor_audit.py, artifacts/, the packaged zips and *_results.json evidence files, plus the working HANDOFF.md and WORKFLOW.md edits and the remaining root sources. Co-Authored-By: Claude Opus 5 --- HANDOFF.md | 496 ++-- WORKFLOW.md | 15 +- artifacts/meta/kfort_d6473f1.zip | Bin 0 -> 30303 bytes .../meta/kfort_d6473f1/bots/kfort/main.py | 1515 ++++++++++++ .../kfort_d6473f1/bots/kfort/mapanalysis.py | 777 +++++++ .../meta/kfort_d6473f1/bots/kfort/mapinfo.py | 301 +++ .../meta/kfort_d6473f1/bots/kfort/path.py | 173 ++ .../meta/kfort_d6473f1/bots/kfort/planes.py | 99 + .../meta/kfort_d6473f1/bots/kfort/symmetry.py | 116 + bot.zip | Bin 28971 -> 15510 bytes conveyor_audit.py | 159 ++ generalist-v2.zip | Bin 0 -> 17713 bytes generalist_baseline.json | 80 + generalist_v2_results.json | 64 + initialSpawning.py | 120 + main.py | 2049 ++++++----------- mapPathfinding.py | 392 ++++ meta-generalist-v1.zip | Bin 0 -> 19711 bytes meta_generalist_v1_results.json | 40 + replay.replay26 | Bin 8415456 -> 170049 bytes tests/test_adaptive_siege.py | 264 +++ tests/test_connected_economy.py | 247 ++ tests/test_core_strategy.py | 180 ++ tests/test_generalist_cycle.py | 364 +++ tests/test_late_route_repair.py | 294 +++ tests/test_meta_armor_rebuild.py | 59 + tests/test_meta_breacher.py | 74 + tests/test_meta_builder_targeting.py | 71 + tests/test_meta_emergency_ammo.py | 43 + tests/test_meta_gunner_aim.py | 72 + tests/test_meta_gunner_defense.py | 100 + tests/test_meta_late_reinforce.py | 54 + tests/test_meta_reinforce.py | 67 + tests/test_v89_vulnerability_cycle.py | 496 ++++ 34 files changed, 7089 insertions(+), 1692 deletions(-) create mode 100644 artifacts/meta/kfort_d6473f1.zip create mode 100644 artifacts/meta/kfort_d6473f1/bots/kfort/main.py create mode 100644 artifacts/meta/kfort_d6473f1/bots/kfort/mapanalysis.py create mode 100644 artifacts/meta/kfort_d6473f1/bots/kfort/mapinfo.py create mode 100644 artifacts/meta/kfort_d6473f1/bots/kfort/path.py create mode 100644 artifacts/meta/kfort_d6473f1/bots/kfort/planes.py create mode 100644 artifacts/meta/kfort_d6473f1/bots/kfort/symmetry.py create mode 100644 conveyor_audit.py create mode 100644 generalist-v2.zip create mode 100644 generalist_baseline.json create mode 100644 generalist_v2_results.json create mode 100644 initialSpawning.py create mode 100644 mapPathfinding.py create mode 100644 meta-generalist-v1.zip create mode 100644 meta_generalist_v1_results.json create mode 100644 tests/test_adaptive_siege.py create mode 100644 tests/test_connected_economy.py create mode 100644 tests/test_core_strategy.py create mode 100644 tests/test_generalist_cycle.py create mode 100644 tests/test_late_route_repair.py create mode 100644 tests/test_meta_armor_rebuild.py create mode 100644 tests/test_meta_breacher.py create mode 100644 tests/test_meta_builder_targeting.py create mode 100644 tests/test_meta_emergency_ammo.py create mode 100644 tests/test_meta_gunner_aim.py create mode 100644 tests/test_meta_gunner_defense.py create mode 100644 tests/test_meta_late_reinforce.py create mode 100644 tests/test_meta_reinforce.py create mode 100644 tests/test_v89_vulnerability_cycle.py diff --git a/HANDOFF.md b/HANDOFF.md index 43c4ae6..c106375 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,290 +1,206 @@ -# HANDOFF — Florent Code League (merged Oogway team) - -Snapshot for a fresh session. Read `memory/merged-team-state.md` + `memory/florent-league-workflow.md` first (auto-loaded), then this, then the top of `florent-code-league.md` for the full IC3D bot history. - -## PERMANENT ITERATION POLICY - -Plan before every non-trivial iteration. Diagnose losses across maps and -unrelated opponents as general weakness categories; never tune by map name, -dimensions, side order, or opponent identity. Build hypotheses independently -from the frozen baseline, require mechanism tests plus graduated parent and -multi-opponent gates, and stack only winners one at a time with a full re-gate -after each addition. Preserve root/active artifacts and require explicit user -approval for upload, queueing, replacement, or activation. See `WORKFLOW.md` -for the mandatory evidence ladder. - -## CURRENT LOCAL CANDIDATE (2026-07-29, `bots/generalist-v3`, not uploaded) - -- **Generalist v3 = live v89 source + one bounded emergency countertrade.** - The existing single nearby responder is unchanged during ordinary threats. - Only when at least two visible turrets really hit the core, no fully safe - counter exists, and the builder stand is outside every attack pattern may it - place one gun whose seat is covered by at most one turret. The generic - 120-Ti reserve is waived only for that trade. Openings, economy, siege, - spawning, roles, stores, map logic, and distant builders are unchanged. -- Replay diagnosis: 8/14 live v89 losses across Ijti, Oogway, and - lastpopperian_ had multi-turret home pressure. The old bounded planner found - valid plans but all 15 pool-wide attempts were blocked at 74-90 Ti by the - reserve. The exception produced **4 real builds from 20 plans** in a - 16-game champion/Showdown mechanism sample, including destroyed-gun rebuilds - at 85/59/37 Ti. Second-family builds remain **unconfirmed**: lastpop2 reached - the planner but never completed the route; do not overstate causality. -- Dev29 gates, 21 maps x 4 seeds x both sides: - **90-78 (54%, kills 79-69) vs live-v89 source**; - **135-33 (80%, 120-21) vs champion**; - **153-15 (91%, 147-14) vs lastpop2**; - **146-22 (87%, 143-15) vs OogwayOld**. - Fresh live-v89-source controls were 74% / 89% / 85%, so all five-point - regression floors pass. Only the champion gain has mechanism support; - treat the other small gains as variance. -- Frozen/package: `bots/generalist-v3`, `generalist-v3.zip`, and - `generalist_v3_results.json` (four Python files at ZIP root). Dev29 - compilation/import, deterministic tests, protocol tests, package identity, - root-v88 identity, and `git diff --check` pass. The mandated legacy - `eval_chain.py` attempt fails before evaluation because that loader does not - add an external multi-file bot to `sys.path` (`ModuleNotFoundError: - symmetry`); it is non-authoritative. -- Rejected this cycle: sequential responder retargeting (77-91 parent, - kills 70-81); three healer-suppression placements (targets detected but - zero builds); bounded countertrade without the reserve exception (plans but - zero builds). Do not stack or revive them without new replay evidence. -- Root sources and `bot.zip` remain frozen v88. No upload, queue, activation, - or root replacement was performed. A remote inactive test requires fresh - user approval. - -## LIVE SUBMISSION BASELINE (`bots/generalist-v2`, Erebus v89) - -- The user uploaded/activated v89. Downloaded submission - `C:\Users\subodh\Downloads\fcode-gate-artifacts\v89-submission.zip` has the - same four uncompressed files as `bots/generalist-v2` / `generalist-v2.zip`. - First observed 40 live games: **26-14** (Ijti 15-10, lastpopperian_ 3-2, - Oogway 3-2, Atlas 5-0). Replays: - `C:\Users\subodh\Downloads\fcode-gate-artifacts\v89-live-replays`. -- v89 = frozen v88 + one exact nearby responder + surgical Sieger-2 - counter-battery. Fresh dev29 controls: **74% champion / 89% lastpop2 / - 85% OogwayOld**. -- Historical v88 remains `bots/core-sniper-v1` and the unchanged root - `main.py`/modules/`bot.zip`; its old controls were 77%/79%/85%. -- Both WSL environments run `fcode 2.3.2.dev29`; the active map pool is - synced. `bots/champion` remains hash-checked through `live_baseline.json`. - -## ⭐ OOGEREBUS — FIRST GATE-PASSED IMPROVEMENT OF OOGWAY'S BOT (2026-07-24 evening, repo 99534e2, bots/oogerebus, GATEKEPT — not for ladder) -- **56% vs stock oogwip (PROMOTE, 32-29 kills), 62% vs krb (no regression).** = stock oogwip + passive siege-tile wall + dev26 crash armor + finisher march + heal-flee fix + print removal + vision guards. -- **THE integration lesson (4th attempt won): the wall must be a PASSIVE — a spare-action side effect when a deny tile happens to be ortho-adjacent — never a state.** State-based walls (oogwip3/4: 32%/46%) starve his dual-purpose econ/defense crew. Passive = zero travel, zero priority competition. Wall set cached once (`_wallSet`): enemy-side ring+ties, cardinal rays d2-3, 2-step diagonals; core-facing conveyors, never-a-dead-end chaining rule. -- Map flips vs stock: hive/quarry/runestone 4/4; still weak: atoll 0/4, vault 1/4 (passive walls only rise where builders walk — vault's rush outruns foot traffic). vs krb: runestone/skerry 0/4 (moved again — map whack-a-mole persists at the margins, aggregate holds). -- Ladder currently: crash-armored krb = v79. dev26 audit: destroy() already ortho everywhere, no ACTION_RADIUS_SQ. CLI now has --json (retool dashboard/gate parsers when idle). OOGWIP STOCK STILL HAS NO CRASH WRAPPER (permanent-death rule) — flag to Oogway. - -## ✅ THE AUGUST 1 DECISION TABLE — controlled 168-game gates, not the ladder (2026-07-28) - -| bot | vs frozen-erebus-v1 | **vs lastpop2 (siege clone)** | vs krb | verdict | -|---|---|---|---|---| -| **oogerebus3** | 44% (mirror tax) | **69%** (kills 107-51) | 85% | **KEEP ON THE LADDER — best vs the only team beating us** | -| OogwayOld | **58%** (strongest overall) | 46% | 55% | best generalist, WORST vs them | -| frozen-erebus-v1 | - | 55% | 63% | the verified all-rounder | -| orion (OogwayOld+anti-clump) | 57% | - | - | 47% vs its own base = no gain | - -**THE CALL: leave oogerebus3 up.** It pays a 44% mirror tax against our own brawlers, which is why it looks mid on the ladder — but it wins the ONLY matchup that decides our placing, 69% with a 2:1 kill ratio. My earlier worry that it was tuned against the wrong proxy (krb, a t29 rusher) was WRONG and is now measured: the same wall that stops a rush also stops the t45-70 siege. -**Field by matchup, never by ELO.** The strongest bot overall (OogwayOld) is the worst of the three against lastpopperian_. - -## 👑👑 OOGWAYOLD IS THE STRONGEST BOT WE HAVE — AND WE BUILT ON THE WRONG BRANCH (2026-07-28) -**`OogwayOld` beats `frozen-erebus-v1` (=oogerebus, our "champion") 58% over 168 games (kills 75-62).** First bot all session to beat it at a trustworthy sample. It is **382 lines to oogerebus's 470** — every kfort/krb feature and fix we added to the OogwayWIP branch netted out NEGATIVE. -**Oogway told us this in commit 61e29d8 ("OogwayWIP is somehow worse than OogwayOld") and nobody measured it**, so the whole oogerebus line was built on the branch he had already flagged as worse. -**⚠ BUT IT IS A SPLIT, NOT A COR ONATION — matchup matters more than overall strength:** - -| bot | vs frozen-erebus-v1 | **vs lastpop2 (the siege clone)** | vs krb | -|---|---|---|---| -| **OogwayOld** | **58%** (best overall) | **46%** (WORST where it counts) | 55% | -| frozen-erebus-v1 | — | **55%** | 63% | - -**The bot that wins the ladder is NOT the bot that beats the team beating us.** Field by matchup, not by ELO. -- `bots/orion` = OogwayOld + anti-clumping: **47% vs plain OogwayOld = NO GAIN.** Anti-clumping gave 54% on the oogerebus line and nothing here — **a mechanism that helps one coherent design does nothing for another.** 17th confirmation of that rule today. -- **The arena ladder is NOISE, do not design from it.** uni-v1 read 71% vs frozen-erebus-v1 there (21 games) and 52% in a controlled 168-game gate. Byte-identical bots read 67-33. Most matrix cells have <6 games. ELO rewards beating whoever you were matched with. - -## 🛑 SEAT DENIAL — REJECTED, AND WHY WE ARE BLOCKED (2026-07-27, bots/seatdeny) -Champion + deny every tile a gunner could hit our core FROM (asked of the engine via `can_fire_from` per tile/facing, so it is exact and survives ray-rule changes), filled with core-facing conveyors, nearest first, 2 builders, until t90. Conveyor is 3 Ti so the whole zone is ~100 Ti of the 500 we start with — walling was never expensive in TITANIUM, only in builder turns. -**Result: 44% vs champion (costs 6 pts) and 64% vs lastpop2 — but the CHAMPION ITSELF scores 67% vs lastpop2.** No benefit, real cost. REJECT. -**⚠ THE REAL BLOCKER: our clone is too weak to be a test instrument.** lastpop2 is 33% vs the champion, so the champion beats it ~67% whether or not it defends the seats — the experiment cannot discriminate. **Until a clone reaches ~55-60% vs frozen-erebus-v1, no anti-siege fix can be validated locally, and every "fix" is a guess measured against the wrong opponent.** Making the clone faithful is the prerequisite for all further defensive work, not a side quest. - -## 🚨 THE LASTPOPPERIAN_ AUTOPSY — WE HAD THEM COMPLETELY WRONG (2026-07-27) -Five of their wins parsed from replays (67fecf4c g1-4, 1d28bf18 g2), separating their HOME turrets from their ATTACK turrets by distance to OUR core: - -| map | their builders | home sentinel | 1st doorstep gun | attack guns | we die | -|---|---|---|---|---|---| -| aurora | 4 | t7 | t59 | 4 @ d3-5 diag | t90 | -| strait | 4 | t13 | t59 | 3 @ d3-4 | t86 | -| bridge | 3 | t4 | t82 | 4 @ d1-3 | t107 | -| twins | 4 | t13 | t84 | 4 @ d3-5 all diag | t109 | -| hive | 3 | t7 | t74 | 3 @ d2-4 | t100 | - -- **THEY ARE NOT A RUSHER.** krb (t29 waves) was the wrong proxy for every anti-rush measurement we made this week — including the tuning of oogerebus3, which is CURRENTLY ON THE LADDER at #2. -- Their shape: 3-4 builders all game, ONE home sentinel at t4-13 (~4-5 tiles out, enemy-facing, covers the approach lane not the doorstep), heavy conveyor econ, then at **t45-70 one or two builders permanently leave the economy**, walk over, and seat 2-4 gunners at **manhattan 2-5, DIAGONALS PREFERRED** (our ring wall only denies orthogonals; a gunner 2 diagonal steps out still reaches). **They rebuild every gun we kill — bridge: same seat 12 times.** Core dies ~25 turns after the first seat lands. -- **⚠ CORRECTION to my first read: they do NOT out-economy us.** On aurora they built 58 entities to our 92. We out-build them and lose anyway. The gap is not economy or builder count — it is that a scheduled, rebuilt siege converts to a core kill and our extra 34 buildings do not. Beating them means answering 2-4 rebuilt diagonal seats, nothing else. -- **The arithmetic of that answer** (from GameConstants): builder attack = 2 dmg → 20 turns per 40hp gunner. Gunner = 10 dmg → 4 turns. **Seat a counter-gunner with line of sight; never plink with builders.** This is exactly the 2nd-place team's "Turret Takedown". -- Clones live in `bots/lastpop` (on mech-v1's econ: 8% vs champion — too weak, econ never funds the siege) and `bots/lastpop2` (on the CHAMPION's econ + lean crew + home sentinel + scheduled siege). Fidelity target is ~60% vs frozen-erebus-v1. - -## 🐛 FOUR SILENT-FAILURE BUGS FOUND WHILE CLONING (2026-07-27) — grep the other bots -1. **`store[0]` is off by one**: the core increments BEFORE the new builder ever runs, so the "first" builder reads 1, not 0. **mech-v1's idx-0 waller role therefore NEVER RAN** (part of its 4%). -2. **Spawn-index race**: a builder born at t0 first runs at t1, by which point the core may have bumped the counter again — so any role derived from `read_store(0)` can land on the wrong bot. **oogerebus3 (on the ladder now) uses this pattern.** Fix used in lastpop2: claim jobs through a FREE store slot (8-15) instead. -3. **Building on your own tile silently fails** (2.3). A bot that walks ONTO its target tile loops forever doing nothing. Require manhattan == 1 exactly. -4. **`can_fire_from(spot, f, SENTINEL, enemy_core)` is always False** — the core is 20-38 tiles away, sentinel range^2 is 32. Gating a build on it means the building is never placed. Face the target instead of asking whether you can hit it. -All four fail SILENTLY — no crash, no log, the unit just quietly does nothing. - -## 📊 ANTI-CLUMPING (canon-v1) — THE ONLY CHALLENGER LEFT STANDING (2026-07-27) -`bots/canon-v1` = champion + `_canonicalFor()`: only the ally builder CLOSEST to a target acts on it (ties by id), computed from each bot's own vision with zero comms. **The champion has NO claim system at all** (grep: 0 hits; khaos has 12) — every builder independently decides to hit whatever it sees, and Oogway's `attackBan` is the crude stand-in (seeing ONE friendly gunner stops that builder attacking ANYTHING for 4-12 turns). -**168-game results: 54% vs champion (kills 68-55), 64% vs krb (champion's own: 63%).** Real but small; 54% at n=168 is ±7.7 so still inside the unproven band. -**`canon-v2` (canon-v1 + emergency defense) = 49% — the emergency-defense idea is now DEAD after four attempts.** The hypothesis that it failed only because every builder stampeded is FALSIFIED: with clumping fixed it still does not help. The `>120 Ti` gate is load-bearing for reasons beyond clumping. Do not try again. - -## 📚 ALL CAMBRIDGE POSTMORTEMS ARE PUBLIC — including 2nd place (2026-07-27) -`https://game.battlecode.cam/postmortems` lists NINE more besides Pantheon: Clankers, Jimboko, MFF1, Make_Fire, Tensor_Bot, The_Blades, cheesynachos, **something_else**, test. **"something else" IS the 2nd place team** (osteo/Jython/Coderz75) — the Pantheon caption "Pantheon (gold) and something else (silver)" is a team name, not placeholder text. Their repo `frkns/cambc26` is private (token returns "not found"), but the 21-page writeup is public. Ideas worth stealing: -- **Turret Takedown:** answer an enemy turret by BUILDING A COUNTER-GUNNER with line of sight to it, placed adjacent to a harvester. Mechanically right for us too: builder = 2 dmg (20 turns to kill a 40hp gunner), gunner = 10 dmg (4 turns). This is the correct form of what oni's adjacent-fire was reaching for. -- **`me_is_canonical_ally` anti-clumping:** partition visible tiles into zones of influence; only the NEAREST ally acts on a target, tiebreak by id. **My emergency-defense attempts let EVERY builder respond at once — likely why they collapsed the economy (26%/18%).** Any future "respond to threat" logic needs this. -- **State commitment:** if they healed a turret in the last 5 rounds and it still needs healing, they re-commit rather than re-deciding. Memoryless bots thrash; a short commitment window fixes it. -- **"Shield pieces":** conveyors/roads surrounding harvesters to prevent easy takedowns — BOTH top teams do the guard-conveyor thing. We only ever applied it to the core ring, never harvesters. - -## ❌ ARTEMIS LAUNCHER HIGHWAY — REJECTED (2026-07-27, bots/artemis-v1) -Champion + Pantheon-Artemis launcher network (builders drop a ferry while marching; launchers throw allies coreward). **46% vs champion / 54% vs krb; constrained to committed attackers only (to stop it catapulting our own miners across the map): 45%.** The mechanic works and is untapped, but it does not pay. Tenth straight challenger to fail against frozen-erebus-v1. - -## 🔦 ENGINE FACTS WE HAD WRONG / NEVER CHECKED (2026-07-27) — read before designing anything -- **`STORE_SIZE = 16`. Our bots only use slots 0-7.** I previously claimed the store was full and that cross-builder signalling was impossible — WRONG. Slots 8-15 are free (exactly what Oogway's unreleased `turretplan.pyc` used for infra sightings / turret records / proposal+claim). Real coordination is available and unused. -- **`BUILDER_BOT_ATTACK_DAMAGE = 2` vs `GUNNER_MAX_HP = 40` → 20 turns of a builder's FULL attention to kill one gunner.** This is the mechanical reason oni's adjacent-fire costs 8 points: every "free" shot is a 20-turn commitment. Any future builder-attacks-turret idea must account for this. -- **`launch()` WORKS ON FRIENDLY BUILDERS — probe-verified** (`bots/launchtest`, log: `LAUNCHED OWN bot 0,7 -> 5,5 d2=26`). Launcher = 20 Ti, throw radius^2 26 (~5 tiles), and the throw is the LAUNCHER's action so the builder keeps its own turn. Walking 5 tiles = 5 turns under act-xor-move; a hop = ~2. **Our champion has NO launcher handler and never builds one — the entire mechanic is untapped.** Naive "throw furthest" ping-pongs a bot between two launchers; always require a gain of >=2 toward the target. -- Other constants worth knowing: STARTING_TITANIUM=500, PASSIVE=10/4 turns, CORE_MAX_HP=500, GUNNER 10 Ti/10 dmg/2 ammo, SENTINEL 30 Ti/18 dmg/10 ammo, LAUNCHER 20 Ti, MAX_TEAM_UNITS=50, HEAL_AMOUNT=4. - -## 🏆 PANTHEON / KHAOS — ALREADY REPLICATED, AND IT LOSES (2026-07-27) -Khaos **was** Pantheon's final bot (Grand Finals winner, named a day before the deadline; progression Starter→Artemis→Demeter→Lethe→Hades→Khaos). oni's port is faithful. **Gated: khaos 20% vs frozen-erebus-v1 (kills 13-54).** Arena: 29%, benched. -**Why it loses here:** ~40% of what won Cambridge doesn't exist in fcode — conveyor-FED turret ammo (2.2 replaced it with a global pool, killing feeder-sniping, turret-feed propagation, dry-turret self-destruct), roads, axionite/foundries, 2000-round games, and launchers that throw ENEMIES. Do not re-port it. -**What IS still unmined, from their EARLIER bot Artemis:** the launcher network "to help ally bots quickly move across the map" — see the friendly-launch fact above. `bots/artemis-v1` = champion + launcher highway (builders drop a ferry while marching, launchers throw allies coreward). Also unmined: **secure-then-harvest** (guard conveyors on all 4 sides of an ore tile BEFORE the harvester, so enemies can't plant turrets next to it) and **ore hopping** (own harvesters as staging points for frontier turrets). - -## 📊 RESULTS TABLE — ONLY 168-GAME NUMBERS (2026-07-27). Treat 84-game gates as unproven. -| bot | vs oogerebus/fev1 | vs krb | vs oogwip | verdict | -|---|---|---|---|---| -| **oogerebus (=frozen-erebus-v1)** | — | **63%** | 61%* | **STILL CHAMPION — 6 challengers, none beat it** | -| uni-v1 (audit-triggered wall) | **52%** | **66%** (vs champ's 63% = TIE) | **48%** | no measurable gain; do NOT ship | -| uni-v2 (uni-v1 + defendCore) | **40%** ❌ | **67%** | — | rejected | -| fev2 (oni: defendCore r=10) | **42%** ❌ | (his A/B ok) | — | costs 8 pts vs non-rushers | -| fev3 (defendCore r=4, my fix) | **49%** | 54%* | — | cost removed, benefit also gone = wash | -\* 84 games, unverified. -**CONCLUSION (2026-07-27): NO CHALLENGER BEAT THE CHAMPION.** uni-v1's apparent rush edge (73% at 84 games) shrank to 66% at 168 vs the champion's own 63% — a 3-point gap at n=168, inside noise (SE~3.7%, 2SE=±7.4). Adjacent-fire defense is a wash. Six mechanisms tried across two days — wall duty, home guard, watchman, micro-wall, gate unlock, audit-wall, adjacent-fire — every one either costs against brawlers or produces no measurable gain. **The blitz weakness is real (diagnosis below is solid) but no cheap fix exists; anything that helps vs rushers is paid for elsewhere.** -**⚠ META-LESSON: at least four "promising" results this week were small-sample illusions** (uni-v1 73→66, uni-v2 61→40, fev2 "no regression"→-8, frozen-erebus-v1 54→43). Byte-identical bots read 67-33 in the arena. **4 seeds (168 games) minimum; treat 45-60% as unproven; cross-check win% against kill differential.** - -## ⭐⭐ ONI'S ADJACENT-FIRE FIX — RIGHT DIAGNOSIS, NOT ACTUALLY FREE (2026-07-26/27) -**oni (Subodh) found what four of my attempts missed: builders can FIRE an orthogonally-adjacent enemy turret since 2.3, and our code predates that rule.** His replay: cored ~r94 by lastpopperian's doorstep gunner cluster *with our builders standing right next to it doing nothing*. `bots/frozen-erebus-v2` = v1 + `defendCore()`: any builder already within manhattan 10 of home fires an adjacent enemy GUNNER/SENTINEL/LAUNCHER. ~25 lines. -- **Why it works where mine failed: it spends NOTHING.** Every fix I tried spent a resource — builder-time (wall duty 44%, home guard 43%), or titanium (emergency gunners 26%/18%). Firing uses the builder's action, which was being wasted. He also independently hit and rejected the same 43-45% home-guard trap ("react, don't recall"). -- **GENERAL LESSON: when the engine changes a rule, audit for capabilities the old code never knew it had.** Same class as the diagonal-gunner meta. Grep for pre-2.3 assumptions. -- `bots/uni-v1` (mine): core audits its own ring at round 22 and only pays for wall duty if the passive wall failed on THIS map. Verified: **52% vs oogerebus over 168 games (no mirror cost — first design to avoid it), 73% vs krb (baseline 62%), but 48% vs oogwip over 168 (baseline 61%).** Better vs rushers, worse vs brawlers — another rock-paper-scissors point, not an escape. -- `bots/uni-v2` = uni-v1 + oni's defendCore (prevention + cure, neither charges the economy). Gating. -- **CALIBRATION GIFT from the arena: `frozen-erebus-v1` beat `oogerebus` 12-6 (67%) in a recent window while being BYTE-IDENTICAL (hash-verified).** Same bot vs itself reading 67/33. Treat any arena gap under ~100 ELO as noise. - -## 🎯 THE LASTPOPPERIAN_ DIAGNOSIS + WHY THE OBVIOUS FIX FAILS (2026-07-26) -**25 unranked games vs lastpopperian_ with frozen-erebus-v1 (=oogerebus): 10-15 games (40%), 2-3 matches.** -- **Their 15 wins by turn: 86, 87, 90, 90, 91, 100, 107, 109, 109, 110, 151, 211, 220, 248, 351. Our 10 wins: 165, 220, 318, 324, 690, 879 + FOUR titanium tiebreaks at t1000.** -- **Ten of their fifteen wins land t86-110. NOT ONE of our wins came before t165.** We lose exclusively to an early blitz; past ~t120 we dominate — and 4 tiebreak wins prove our ECONOMY IS ALREADY BETTER than theirs. Blitz maps: strait 86, longship 87, aurora 90 x2, quarry 91, hive 100, bridge 107/110, twins 109 x2. Our maps: showdown x2, string x3, pinch, vault, fjord, atoll. -- **⚠ THE `>120 TITANIUM` GATE ON THE ATTACK STATE IS LOAD-BEARING, NOT A BUG.** It looks like the kdef money-gate bug (defense disabled while poor) and it is tempting to remove — DON'T. `bots/fe-def` tried three ways: (1) alarm + recall + score-10 override = 24%/17% (permanent panic, economy starved); (2) minimal gate unlock only = 33%/15%; (3) same after fixing a real latch bug I introduced (`attackBan` never decremented because an emergency skipped the else-branch) = **26% vs krb / 18% vs oogerebus**. The gate stops the bot bankrupting itself on defensive gunners. Removing it collapses the economy every time. -- **The measured answer to the blitz is oogerebus3** (wall duty seals the doorstep by round 45, well before their t86 window): 24-0 vs krb in the arena, 85% gated. Field it as the matchup weapon; it costs ~44% in the oogerebus mirror, which is why it is not the everyday bot. - -## MECH-V1 — SIMPLE WALL+RUSH BOT FROM SCRATCH: 4% (2026-07-26, bots/mech-v1) -User's design, built clean (no khaos/Oogway lineage): idx0 walls the enemy-side ring + firing lanes, idx1-4 economy, idx5+ rush the enemy core planting gunners via `can_fire_from`. **Gates: 4% vs oogerebus (kills 1-74), 6% vs krb (4-76).** -- **Why it loses: NO defensive response at all** — no healing, no counter-gunners, no reaction to attackers. 74/84 losses were our core destroyed. The rush itself works fine (vs nop: core kills t95-138, 640 mined). -- Economy lessons the hard way (worth reusing in any from-scratch bot): (1) chaining conveyors BACKWARD from a harvester leaves gaps when a tile is blocked → orphan harvester → **0 titanium mined all game**; grow the network OUTWARD from the core and only plant harvesters already touching it — connectivity by construction (0 → 550 mined on aurora). (2) econ bots that only look at ore in vision park at the core forever on big maps; they need an outward sweep. (3) A rush is only as big as the economy funding it: 2 econ bots → 270 mined and rushers standing broke with 3 buildings; 4 econ bots → 640. -- **Verdict: a from-scratch bot cannot catch bots carrying weeks of accumulated fixes in one session.** If revisiting, the single biggest lever is a defensive response (heal the core + counter-gunner when an enemy is near home), not more rush. - -## 🚨 GATE VARIANCE — 2-SEED GATES ARE NOT TRUSTWORTHY FOR CLOSE CALLS (2026-07-26) -Same matchup (frozen-erebus-v1 vs oogerebus), same code, same engine: **seeds 1-2 → 54% (NEUTRAL, looked promotable). Seeds 3-4 → ~33%. All four seeds → 43% (REJECT).** An 84-game/2-seed gate can be ~20 points off. **Any verdict in the 45-60% band must be rerun at 4+ seeds (168+ games) before it is believed.** Corollary: past PROMOTE calls in the 55-60% range (several this project) are NOT established — re-gate before relying on them. Kill differential is a useful corroborator: at 168 games fe1 was 51-76 on kills, matching the loss; when win% and kill-diff disagree, distrust the win%. - -## FROZEN-EREBUS-V1 ATTEMPT — REJECTED (2026-07-26, bots/frozen-erebus-v1, NOT shipped) -**Final: 43% vs oogerebus over 168 games, and 45% vs OogwayWIP where oogerebus scores 61%.** The home guard is a net negative; its only virtue was krb (67% vs oogerebus's 62%). oogerebus keeps the crown. -Goal: one bot that dethrones all ours (collapse the oogerebus / oogerebus3 rock-paper-scissors). Build = oogerebus + **home guard** (first-spawned builder restricted to home zone doing FULL economy, so its foot traffic feeds the passive wall — instead of oogerebus3's dedicated waller). -- **Guard window is the whole tradeoff dial.** Guard until t60: 45% vs oogerebus / 70% vs krb. **Guard until t30 (shipped setting): 54% vs oogerebus / 67% vs krb** — near-all the anti-wave gain at half the cost, because the rush lands ~t29. skerry smoke 0-4 → 3-1, t55 massacres extinct (t151-235). -- **Rejected on the way (each isolated, one variable at a time):** `fe-ammo` (buffer 20→40/60 when rich) = 54% mirror but only **52% vs krb** vs oogerebus's 62% — ammo conversion steals early defensive titanium; NOT the pure gain it looked like, and it retroactively explains part of oogerebus2's failure. `fe-idfix` (spawn-order role gates replacing `get_id()>4`) = near-no-op: replay shows team A builders get ids 3,5,8,13 and team B 4,6,9,14, so `>4` excludes exactly one builder per side — symmetric by luck. (It DOES confirm the oogerebus3 `<=3` bug was real: that threshold caught A's id-3 and nothing on B.) -- **⚠ Mirror gates between near-identical bots regress to 50% by construction** — a 95%-shared variant can be genuinely stronger at 54%. Judge a dethroner by its record vs THIRD-PARTY bots (oogerebus: 62% krb / 61% oogwip), not by the parent mirror. -- **⭐ THE FINDING: home-restricting a builder costs ~11 points vs non-rushers REGARDLESS of what it does there.** Guard (productive economy) 45% and oogerebus3 (pure walling) 44% pay the identical price — but full duty buys 85-100% vs waves. The cost is the restriction, not the walling. Any future "keep a builder home" idea inherits this ~11pt tax; only spend it if the anti-rush payoff is large. -- ⚠ **GATE HYGIENE: `lab.py X OogwayWIP` returned 0 wins / 0 kills BOTH WAYS across 84 games — that is an INVALID run, not a loss.** The local folder is `bots/oogwip` (OogwayWIP only exists in the team repo). Zero kills in both directions = bot didn't exist. Always sanity-check kill counts before believing a 0%. - -## ⚠ ARENA ELO ≠ TRUE STRENGTH — READ THE PAIRWISE MATRIX (2026-07-25) -Arena ELO ranked OogwayWIP #1 (1790) but the **pairwise head-to-head says oogerebus beats EVERY bot**: OogwayWIP 15-9, oogerebus3 16-8, kfort 18-6, krb 14-10. OogwayWIP's ELO is inflated by farming weak bots (91% vs kfort, and khaos/v85/public were on the ladder). **oogerebus is the true champion.** oogerebus3 is a hard counter to wave bots (krb 24-0, kfort 23-1) but loses to non-rushers — a rock-paper-scissors, not a hierarchy. Always pull `/api/state` and aggregate `matches[].games` pairwise before believing the ELO column. - -## SKERRY ARC → THE TWO-BOT DOCTRINE (2026-07-24 night, repo 24491ff) -- **Arena ELO misled us:** controlled 18-game run showed oogerebus 72% over stock on the arena's own maps — the "oogerebus is behind" reading was 6-game-match ELO noise. Rule: arena ranks roughly; equal-seed lab runs settle close calls; <60 ELO gaps = noise. -- **oogerebus2 (sentinel artillery) REJECTED**: 54% vs stock / 56% vs krb — outranging theory didn't survive the Ti+ammo cost. Kept local only. -- **Skerry autopsy** (krb 4/4, kills at t55!): krb ring gunner at t29; oogerebus built ZERO wall — the passive wall needs foot traffic and skerry's ore spread empties the home zone instantly. Fix = **wall DUTY: first-spawned builder walls the enemy-side ring rounds 6-45** (spawn order via store slot 0 — ⚠ entity IDs INTERLEAVE ACROSS TEAMS, an id<=3 gate silently matched nobody as player B). -- **Design space fully measured:** full duty = skerry 11-5, **krb 85% (63-11, best anti-wave number ever)**, but 44% parent mirror. Watchman (wall-on-sight) = always too late (t55s return). Micro-duty (3 tiles) = wave sidesteps (8 identical t62 kills). **Doorstep denial only works whole — third independent confirmation.** -- **Resolution: TWO SPECIALISTS in the roster** (bots/): `oogerebus` = mirror-strong generalist; `oogerebus3` = anti-rush specialist (85% vs the wave meta that August will be full of). Field per expected opponent; the arena tracks both. - -## OOGWIP INVESTIGATION (2026-07-24 afternoon — full findings for Oogway, bots/oogwip2-4 kept as evidence) -- **How his bot works:** 5-state scorer, but a BRAWLER: attack (10) plants a gunner adjacent to self facing any enemy within ~6 tiles — 8-way facing natively, destroys own conveyor to make room, money-gated at Ti>120, anti-spam ban near friendly guns. Idle gunners re-aim by scoring all 8 directions. Low-id crew (id<=4) never attacks = permanent econ+defense core. Kills krb by instant point-blank counter-gunnery — reaction wins ONLY in this instant/point-blank regime. -- **Bugs found (objectively correct fixes, no regression):** per-turn print() in builderBot; heal() tries flee+heal same turn (act-xor-move); wall experiments also hit GameError on out-of-vision get_tile_building_id (his codebase needs is_in_vision guards). -- **Weak maps vs krb: bridge/skerry/vault 0/4** — doorstep rush, he has no wall. BUT wall ports failed: oogwip3 (wall ceiling 7) = 32% vs stock — HIS DEFENSE CREW IS HIS ECON CREW, wall outranking route starves the bot; oogwip4 (ceiling 5, round>10) = 46%/64% — fixes showdown+sweden (4/4), breaks bridge+quarry (0/4). Whack-a-mole ⇒ integration needs his own econ-curve judgment. **bridge failed in EVERY variant — likely NOT a doorstep problem; needs its own replay diagnosis.** -- His finisher gap (never marches on enemy core; corner-explores) — oogwip2's mirror-march finisher was neutral in mirrors (both brawl mid-map) but should rescue tiebreak games vs passive bots; untested vs that profile. - -## 👑 OOGWAYWIP IS THE NEW BEST BOT (2026-07-24 ~13:00, gated on 2.3.0.dev25/tle10/21maps) -- Oogway's HAND-WRITTEN bot (bots/OogwayWIP in team repo; local copy bots/oogwip): **65% vs krb (49-27 kills), 71% vs diag-besvimic (57-24)** — beats the whole khaos lineage. Smoke: suffocated krb to 60 mined all game. Style: aggressive econ denial + kills. -- **Team decision stands: GATEKEEP it** (finals weapon, develop off-ladder; krb/kfort keep farming rating publicly). Its weak maps for Oogway to look at: bridge 0-1/4, skerry 0-1/4, vault 1-2/4. oni's open question: does it handle 2.3 builder-heal? (its besvimic losses may be heal-response gaps). -- Team context: Pantheon (the actual Cambridge winners) JOINED the league; 2nd-place repo got privated before anyone cloned. oni's 72%-kfort measurement = stale env (60 games ⇒ 15-map pool, likely dev23 engine) — measurement standard now pinned in WORKFLOW.md (cdf5708). oni swapped ladder to kfort; krb 54-56% over kfort on current env — close either way. - -## ⭐⭐ KRB "KHAOS-REBORN" — THE ALL-IN (2026-07-23 late night, ladder v75, main 808cefc, bots/krb) -- Built the two missing "abuse" pieces on the kforta/v74 base: (1) **8-way gunner facings in planes.py** (diagonal stacks NE/NW/SE/SW, ray len 2 — shift = composed card shifts; the fk→Direction map in _run_attack 'plant' extended); (2) **synchronized wave opening** (`_strike`: myNum≥3 sprint to the symmetry-predicted enemy core, stage at d²60, push when 3 buddies within Cheb 4 (or r90 solo), _ringGuns cap 5 during strike, opening expires r220; guard #1 walls home, #2 runs econ). -- **Gates: 82% vs khaos (62-11 kills), 64% vs diag-besvimic, 56% vs kforta v74 — beats EVERYTHING including our own wall.** Wave weak maps: sweden + showdown (big/late-arrival). **vs lastpopperian_ live: 3-2 + 3-2 — BOTH matches won (first consecutive series wins vs them; day arc 3/15 → 6/10 with 2-0 matches).** Their remaining wins are t86-89 blitzes on aurora/fjord — their fastest lanes still outrace the wave there. -- **Strategic posture: v75 (all-in) live to farm; kforta v74 (wall) is the counter to the all-in shape and stays one submit away if the ladder copies the meta.** Both in team repo bots/. -- **⚠ RING SAGA CLOSED (2026-07-24 ~02:30, three straight REJECTS — v75/krb IS the local optimum):** the pinch t97 loss (user-reported "we did nothing") = enemy gunner on an east-side ring tile the enemy-facing-half math excluded (seam #3). Fix attempts ALL rejected by the gate: **krb3 full-ring seal 29%** — under 2.3 ortho-heal, ring tiles are the ONLY positions the core can be healed from; sealing all 12 = unhealable core (THE deep 2.3 fact of the night). **krb4 guard-post on the open tile 49%/52%** — a parked guard's opportunity cost exceeds the block value. Ring tiles are triple-purpose (enemy gun spot / spawn exit / hospital bed); v74's enemy-side-half + counter-battery is the best known policy. Remaining doorstep losses = close-quarters race maps (pinch/hive/aurora/sprint-class) where the enemy commits by t18 — ONLY fixable by opening speed (earlier striker deployment — the scoped work with oni). Rejected refs kept: bots/krb2 (no-wait), krb3 (full seal), krb4 (guard post). -- **GATE-VALIDITY EXPERIMENT (2026-07-24 ~03:30, user challenged 4 straight rejects — "code wrong or gates wrong?"):** tested the strongest rejected candidate (krb5: enemy-facing striker spawns + 2-buddy wave) LIVE vs lastpopperian_ as v76: 8-7 games (53%) vs v75's 60% baseline over 50 games. **The gates were right — rejects are real, not lab blindness.** Reverted to v75 (=v77 zip). Caveats logged: our sparring set has no lastpopperian mimic (build "popmic" from their replays if this matchup needs lab visibility); krb-vs-krbX mirrors carry artifact risk (old v85 lesson). krb5's real finding: fast opening flips sweden+fjord (4/4 both gates) — a per-map opening profile is plausible but needs fresh-seed validation, not tonight's data. v75 opening-speed structural work (same-turn arrival via store protocol) remains the scoped next build with oni. -- **2026-07-24 ~01:00 gauntlet vs top 2:** lastpopperian_ series 4-1 (W3-2, W4-1, L1-4, W4-1, W3-2; 15-10 games). Their match wins = t85-101 blitzes on hive/aurora/sprint (they commit builders earlier than our wave). **krb2 (skip staging on small maps) REJECTED: 54% besvimic / 42% vs krb (30-46 kills) — the buddy-wait IS the wave; solo arrivals die piecemeal.** Closing the last match needs earlier striker spawns (opening surgery, do with oni), not less discipline. Ladder now a 2-horse race: lastpopperian_ 2136 #1, us 2107 #2, Besvikomat COLLAPSED to 1725 #3 (sentinel meta died). Besvikomat unrated set: 1st match W 4-1, rest queued. - -## ⭐ V74 — THE USER'S ALL-CONVEYOR WALL BROKE THE PLATEAU (2026-07-23 night, ladder v74, main 00f1a4b) -- User spotted it live: the barrier ray tiles were walling off OUR OWN trunk conveyors — titanium never reached the core in many games. Fix: **the entire wall is conveyors chained coreward, built inside-out** (ring first; an outer tile only builds when the tile it faces already holds our conveyor/core). ⚠ CRITICAL LESSON: an UNCONNECTED wall conveyor is a titanium grave — the harvest router wires trunks into any friendly conveyor, so one dead-end tile swallowed whole chains (first attempt: 160 mined/game vs 1500). Connectivity rule is load-bearing. -- **Gates: 64% vs khaos (best ever), 56% vs diagonal-besvimic (first result above the 50/52% plateau).** The plateau was never defense mechanics — it was the wall starving our own economy. -- v74 vs lastpopperian_ live test queued (v73 was 6/10). planes.py diagonal offense still open (oni's system — coordinate). - -## (superseded) DEFENSE PLATEAU NOTES (v73, main 5675cb1) -- **v73 shipped** = v72 + ring-first build order (their t70 doorstep gunners beat the old order), ring boundary-tie seam closed, diagonal lanes as SOFT +5 walker cost (`threat_diag()` in mapinfo + DIAG_EXTRA class in path.next_step — kept OUT of `threat()`: hard 8-way there strangled routing/placement, gated 54%). -- **Three defense iterations vs diagonal-besvimic: 52% → 48% → 51%. Plateaued.** vs khaos: 62/54/58 ≈ noise band. Defense-side work is extracted; local bots kept `bots/kforta` (=v73), `bots/kfort4` (54% reject version with hard 8-way mask). -- **vs lastpopperian_ live: v71 3/15 → v72 3/10 → v73 6/10 (won a match 4-1!).** Ring-first killed their t100 doorstep wins (slowed to t165+); we out-rush them now (kills t102-132 for us); tiebreaks split 3-3. The defense stack works live even though the lab said "parity" — lab mimics don't reproduce their exact opening. Remaining edge to close: planes.py diagonal offense (below). -- **⭐ THE remaining lever: planes.py diagonal facings** (offense — our guns cannot take the diagonal shots that beat us). Design: 4 more plane stacks keyed NE/NW/SE/SW, shift = ±1±W with BOTH column edge masks, gunner ray len 2 (d²8≤13), same 0.9^k discount. Then _bestPlant/_run_attack 'plant' need the 8-way facing map. This is oni's system — coordinate with him. -- besvimic upgraded to diagonal siege (its old numbers dead). jackpot attrition finding: we spent 34 builders vs their 11 — diagonal lanes were invisible to pathing (now soft-costed in v73). - -## ⚠⚠ DIAGONAL GUNNER META (2026-07-23 afternoon — THE 2.3 story) -- lastpopperian_ 5-0'd us rated (-21.8, crossfire core kill t68) with **diagonal-facing siege gunners** — 2-step diagonal spots (d²=8≤13) beat kfort's cardinal-only wall AND the distance-1 ring. Sentinel nerf made this the best siege in 2.3; expect everyone to copy it. -- **Defense fixed & shipped (ladder v72, main 636c964):** deny tiles now include the 8 two-step diagonals; ring-corner conveyors BLOCK 1-step diagonal rays (gunner shoots the conveyor, not the core); wall builds enemy-side-first (act-xor-move slowed construction — old build order left the hot side open). Controlled proof: diagonal-besvimic kills old wall t93 on crossfire, loses to fixed wall (t703 core kill for us). Gates: 62% vs khaos (crossfire 4/4 first time), 52% vs diagonal-besvimic (kills 34-40 AGAINST — see next line). -- **besvimic upgraded to diagonal-shooting** (its siege facing loop now tries all 8) — it's the lastpopperian_-class sparring bot now; old besvimic numbers not comparable. -- **⭐ NEXT LEVER — OFFENSE: planes.py scores CARDINAL facings only ('E','W','N','S')** — our own attack cannot place diagonal gunners. Whoever weaponizes diagonal siege offensively farms the ladder. Add 4 diagonal facings to the plane scorer (shift math: ±1±W with double edge masks). Weak vs diagonal rush still: hive/longship/runestone 0/4, atoll/aurora 1/4 — likely need offense parity, not more wall. -- Told team via user. Watch v72's rated trend. - -## ⚠ ENGINE 2.3.0.dev25 MIGRATION (2026-07-23 morning — supersedes some of the below) -- Breaking rules: builder act = ORTHO-adjacent only (no own-tile/diagonal), sentinel = 1-tile line (band gone, sentinels nerfed → gunner meta favors us), act XOR move per round. Engine silently refuses illegal calls — unmigrated bots limp, don't crash (~1% head-to-head cost measured, kfort4-migrated vs unmigrated = 51%). -- **kfort migrated (7 sites: ortho gates in harvest/heal/plant/deny/ring/choke, self-heal removed, no more diagonal TTL-poison) → ladder v71, main cf107e9.** Gates on 2.3: 60% vs khaos PROMOTE. -- **ROOT team bot (main.py on main) is NOT migrated — teammates need the same 7-pattern sweep.** planes.py sentinel band scoring also stale (over-values sentinels) — unfixed everywhere. -- Map meta shifted: showdown now WINNABLE (3/4, 2/4 — sentinel nerf likely; was 0/4 forever), hive new sore spot (0/4 vs khaos). Rating 2133 #1 of 25 (a rated dip happened overnight on unmigrated v70 — watch v71's trend). -- The 6 missing maps are now in BOTH repos' maps/ (pushed to main). - -## ⚠ NEXT SESSION FIRST (2026-07-23, end of night) -- **Branch switch: work on MAIN now** (user's instruction; local oogway-bot already checked out + pulled). Teammates merged everything — gate, WORKFLOW, kfort, khaos, v85, maps — into main (40 commits) and the ROOT bot is now khaos-based (main.py rewritten, khaos modules at repo root). Review those 40 commits. -- Oogway committed `homedefense.cpython-311.pyc` + `turretplan.cpython-311.pyc` (pycache only, still NO .py source, root main.py does NOT import them yet) — keep chasing the source files. -- Main's maps/ has only the old 15 — push the 6 new engine maps (bridge/jackpot/showdown/string/sweden/vase) to main. -- **Study replays saved:** `a0e24084-*_game_1` (showdown rated loss t139 — 3rd showdown failure, lab+unrated+rated; top priority), `_game_3` (quarry grind loss t776), `_game_5` (vase tiebreak loss — 2nd vase econ loss). From the first rated match with kfort4 (v70): lost 2-3 to Besvikomat, -4.3 ELO, single-match noise but bad-map draw (showdown+vase in one bo5). Won crossfire rated (!). -- Rated trend overnight = the v70 verdict. Revert path: v69 (kfort v1) → v68 (khaos). -- Team RENAMED: **Erebus** (was Oogway). - -## Where things stand (2026-07-22) -- **Team:** merged into **Oogway** (id `56ced8d6-c986-4ebd-9b0b-ff53de527a85`, ~2148, #2, chasing Besvikomat #1 ~2157). Old IC3D team deleted; CLI credentials already repointed to Oogway (backup `~/.fcode/credentials.json.bak`). -- **Engine:** fcode **2.2.0.dev23** — 4-way movement, global ammo pool (`convert_ammo`). Verify with `pip show fcode` at start; staging pushes breaking updates. -- **Repos:** - - `C:\Users\IC3D_\Desktop\ic3d-battlecode` — IC3D solo bot (v85-mirror-rush) + ALL tooling (lab.py, econ.py, mimics, dashboard.py, plan file, HANDOFF). - - `C:\Users\IC3D_\Desktop\oogway-bot` — merged-team bot (github.com/Varainrain/fcode), branch `ic3d-dev`. Commit as the user, **no Claude attribution**. -- **Key fact:** IC3D v85 beats Oogway's base **82%** and out-mines it (91k vs 34k) on the current engine → IC3D is the stronger chassis; Oogway's value is his modules. - -## Assets in hand -- **Tooling:** `lab.py` (gate: bot vs opponent, 60 games, PROMOTE/REJECT), `econ.py` (mined Ti vs nop), `replay_stats.py` (⚠ gunner counts 4x-inflated by rotations; sentinels exact), mimics `bots/barbimic|pendmic|besvimic`, `bots/champion` (frozen baseline). -- **Dashboard:** `dashboard.py` port 8642 (WarRoom.bat) — hero=Oogway, Rival=Besvikomat, Repo panel. -- **Oogway's shared modules (in this dir):** `globalMap&pathfinding.py` (bit-packed shared team-map over store slots 0-6 + Dial's weighted Dijkstra — powerful but runs full-map per-bot-per-turn, NEEDS CACHING; has a crash bug: `bestDir` None deref) and `initialSpawning.py` (spawn-ray ore optimizer). - -## Next tasks (priority order) -1. ~~**Package the team gate harness**~~ ✅ DONE 2026-07-22: `gate.py` + `WORKFLOW.md` + `fcode.toml` + `maps/` + frozen `bots/champion/` (= current ladder bot, root files) committed to oogway-bot `ic3d-dev` and pushed (commit 2185642, new remote branch — PR to main still open to Oogway). Gate verified on dev23: mirror gates 42% → REJECT, same-name guard works. Usage: `python gate.py ` (60 games), `python gate.py champion 1 fast` (12-game screen). -2. ~~**Siege bake-off**~~ ✅ DONE 2026-07-23, 240 games. Sieger knobs added to bots/ic3d/main.py (`SIEGE_RUSH_GUNS/SIEGE_SEAL/SIEGE_SENTS`, behavior-neutral at 3/True/3). Results vs champion: barrier-only (sgbar) **35% REJECT** (4 kills for, 27 against — the seal alone loses the kill race in 2.2); gunner-only (sggun) **50%** (20-14 kills, wins 63% vs sgbar head-to-head); 6-guns+seal (sg6) **50%, exactly 2/4 on all 15 maps** (= behaviorally identical, side/seed decides; guns past 3 rarely matter). **Verdict: v85's existing 3-guns+seal+sentinels synthesis stands. Gunners are the kill weapon, the seal is the force-multiplier, barriers-alone is dead.** Variant bots kept in bots/sgbar|sggun|sg6, logs lab_sg*_champ.txt, lab_sggun_sgbar.txt. -3. **Chassis decision: ONI'S KHAOS WINS** (2026-07-23, both benchmarks same day): - - Oogway's latest dev23 bot (`bots/oognew` in ic3d-battlecode): **25%** vs v85 (8-32 kills), mines 24.5k vs 91k. But sweeps crossfire 4/4 / atoll 3/4 (v85 weak maps — replay-worthy). - - **oni's khaos** (`bots/khaos` in oogway-bot, = the live ladder bot "v68 khaos", 6 modules incl. mapanalysis/symmetry/planes): **beats v85 73-27** (31-7 core kills; gate_results.csv in oogway-bot). v85 shut out on aurora/crossfire/duel/pinch/sprint; v85's only strong maps fjord+quarry (3/4). - - **Merged bot builds on KHAOS.** ⚠ ECON CORRECTION (same day): raw totals (12k vs 75-91k) were a game-length artifact — khaos kills nop at t57-145 so its total is truncated. PER-ROUND rates: khaos 8,928/1k vs v85 11,085/1k (~80%), and v85's edge is late-game grid compounding (strait/twins 25k/1k at t1000). khaos's early econ is FINE — do NOT port v85's economy wholesale. ANSWERED vs besvimic: khaos wins 65% (27-17 kills, PROMOTE-grade). Of its 21 losses, 17 = COUNTER-KILLED (often t80-t230, weak maps fjord/crossfire/vault all 1/4), only 4 = tiebreaks. **⭐⭐ KFORT v4 SHIPPED (2026-07-23, repo b636d6c, ladder v70):** the USER'S guard-conveyor idea won the 4-way bake-off — ring tiles denied with CORE-FACING CONVEYORS (delivery flows through the wall) instead of barriers. **Gates on the full 21-map pool: 65% vs khaos (43-18 kills), 80% vs besvimic (55-7).** v2's disaster maps (bridge/jackpot/string/sweden 0/4 with barrier ring) flipped to 4/4-3/4 — the choke was OUR OWN delivery lines hitting the barrier ring. Version ladder: v1 open ring 56%/65%, v2 always-barrier-ring 40% REJECT, v3 reactive-barrier-ring 54.8%/73%, v4 conveyor-ring 65%/80%. Remaining weak maps: showdown (0-1/4), crossfire, vault/runestone vs besvimic. Map pool now ALL 21 engine maps in both repos (six were missing locally: bridge/jackpot/showdown/string/sweden/vase — dev23 package ships them). Local variants kept: bots/kfort4 (=shipped), kfort (v3), kfort1 (v1), sg*/kdef* (rejected, reference). Older v1 notes: khaos + two bolt-ons from the Pantheon postmortem analysis: (a) `_denyHomeSiege` + `_siegeRayTiles` — pre-barrier the gunner firing tiles (distance 2-3 cardinal rays off the 2x2 core; distance-1 ring left free for spawns), home guard (builder #1) patrols un-denied tiles; (b) `_ringGuns` — maintain up to 3 gunners on the ENEMY core's adjacent ring (spawn-camping, v85's move made deliberate). **Gates: 58% vs khaos (PROMOTE, 32-17 kills), 65% vs besvimic with kill diff 37-7 (khaos was 27-17 — siege deaths collapsed).** Vault s2 (the reference loss): survives to t1000 instead of dying t155. Weak map: crossfire (0/4). Margin is modest (58%) — live ladder is the final judge. Denial is always-on; a reactive version (build wall only when enemy sighted on our half) is the next knob if econ cost shows. Earlier failures for context: **kdef experiments: (1) anti-siege must act EARLIER — two reactive patches both failed the gate: kdef (home counter-battery exempt from the scaling money gate) 48% vs khaos / 63% vs besvimic = inert; kdef2 (+perpendicular lane placement) 42%/50% = REJECT, the placement block is shared with offense and the bias broke attack planting. Root cause per vault replay: once a barrier-shielded siege stands, reactive guns feed it (470 dmg bled). Fix must deny establishment (launcher intercepts on approach / spawn pressure) — mine the Cambridge 2nd-place repo (oni's sending link) for mechanisms. (2) Oogway's shared-map/pathfinding port is REDUNDANT — khaos already has Pantheon Dijkstra (path.py) + store map-sharing (mi.share). (3) late-econ compounding — last, ~4/60 games.** Variants kept: bots/kdef, bots/kdef2 (rejected, reference only). lab.py now capped at 6 workers (khaos OOM). econ.py now prints turns + rate/1k (early-kill aware). Earlier session's "v68 is bleeding rating, swap it out" read was WRONG — khaos is the team's best and holds #2; the dip was Besvikomat improving. - - Gate note: gate.py now capped at 6 workers — 12 parallel engines with khaos's heavy modules exhausted Windows commit memory (WinError 1455 + phantom t0 "draws"). -4. **Oogway's Dijkstra — downgraded from blocker to nice-to-have** (Discord 7/21): our "5ms avg / 90ms spikes" profile was a bad benchmark — it hardcoded all 8 bots pathing to ONE far tile, forcing full-map expansion. Real usage early-exits when the wavefront hits the bot; Oogway measures <1ms after the first rounds. Caching still helps the far-target worst case (retreats/attacks across 30x30 finals maps) but isn't required to adopt it. The `bestDir` None crash IS real — Oogway said he'd add the guard himself; verify it landed before porting. - -## Working style -User talks casually — draft any Discord messages in lowercase/slang, never formal. Be honest, correct mistakes (theirs and yours) plainly, act rather than defer, don't suggest stopping. +# FCode production handoff + +Updated 2026-08-02. Read this before changing a bot. `AGENTS.md` contains the +iteration rules; `WORKFLOW.md` contains the gate protocol. + +## Current decision + +- Strongest local candidate: `bots/meta-generalist-v1`. +- Parent: `bots/exp_trans_40`, the shipped-v8 source lineage. +- Only change: gunners now recognize enemy builders on the engine's separate + builder layer. The parent fired/rotated only when an enemy building occupied + the tile; a lone delivery builder was invisible. +- Frozen package: `meta-generalist-v1.zip`; manifest: + `meta_generalist_v1_results.json`. +- Local only. It has not been uploaded, queued, activated, or copied to root. +- Root `main.py` / `bot.zip` remain the historical v88 package and are dirty in + git from prior user work. Do not replace them without explicit approval. +- Production status cannot be queried with the current CLI session: the local + WSL environment is staging `fcode 2.3.2.dev29` and its session is expired. + The user says the real competition has started. Re-authenticate/update before + any remote verification and never describe local dev29 gates as production. + +## Candidate evidence + +All gates use 21 maps, both sides, `--tle 10`; fresh blocks only. + +| Opponent | Result | Core kills | Interpretation | +|---|---:|---:|---| +| `exp_trans_40` | **200/336 (60%)** | **181-127** | decisive parent win | +| `spar_rush` | 88/168 (52%) | 81-64 | neutral/slightly negative | +| v8 control vs `spar_rush` | 92/168 (55%) | 85-64 | comparison control | +| `oogbest-v6` | 86/168 (51%) | 83-67 | source-diverse parity | + +Parent-gate mean titanium collected was 819.1 vs 718.0; buildings 19.2 vs +18.4. The source change cannot alter economy directly. Deterministic tests prove +enemy builders fire and friendly builders do not. + +Validation completed: + +- all `tests/test_*.py` files pass when run directly in the WSL venv; +- `pytest` is not installed, so `python -m pytest` was unavailable; +- frozen sources compile and a Duel smoke game completed by core destruction; +- `git diff --check` passes (line-ending warnings only); +- ZIP contains exactly four Python files at root and is byte-identical to the + frozen folder; +- mandated `python scripts/eval_chain.py` attempt fails because this repository + has no `scripts/eval_chain.py`. Record this as missing legacy tooling, not a + pass. + +SHA-256: + +| File | SHA-256 | +|---|---| +| `main.py` | `A9EC959ECAE2E0B11EDB3DAA89AD3A3CD480196C903FEB543F8059B76C2D965C` | +| `initialSpawning.py` | `E6D48213A505729ED98BBBC1B55623484BC83B202B2E78D4FA9E929686D40BB6` | +| `mapPathfinding.py` | `971A102A26E8792E6B0FED6FE8F7710641FD1D03D299322F97423A43342AE4A6` | +| `symmetry.py` | `8C4AB5843AB90F8C0907E261E2049B75588BC88BFB8CEEA2CC236E3CD10CD9A1` | +| ZIP | `4F94E7301B99EC34283C82F6FAFDE7C61F03135C9702F4792AE3D3BC6642EE07` | + +## Production-era lineage + +| Line | Evidence / status | +|---|---| +| root v88 | historical package, preserve | +| `generalist-v2` | v89 source; early live sample 26-14 | +| `generalist-v3` | bounded countertrade; later live v3 was rank #1/86 at 62%, but top-four window only 29% | +| `exp_siege_on_sight` | shipped live v2; 67%/336 vs v3, 52% vs rush | +| `exp_recall2` | soft v6 recall; hard recall rejected | +| `exp_trans_40` | shipped v8 lineage; economy stops at t40; 60%/504 vs recall parent, but live top-five trade rather than clear gain | +| `meta-generalist-v1` | current local candidate; gunner builder-layer bug fix | + +The exact active production version must be verified after re-authentication. +Commit messages say transition40 was shipped as v8; do not infer activation from +the local root files. + +## Current meta evidence + +Production replay measurements across unrelated top teams: + +| Team archetype | First gun | Guns near enemy core | Economy / defense | +|---|---:|---:|---| +| Oogway v7 | t24 | 2.2 | 13.4 conveyors, 3.0 harvesters | +| Orizon | t18 | 16.0 | 8.0 conveyors, 1.9 harvesters | +| Prompt Engineers | t26 | 14.0 | 3.2 conveyors, 1.0 harvester | +| Flotte | t22 | 6.5 | no economy; ~5.7 home barriers | +| Pantheon | t22 | 18.7 | 17.7 conveyors, 4.1 harvesters, ~39.8 barriers | + +General conclusions: + +- The field builds economy, then converts actions into core pressure. Fixed + early all-in allocation starves income; v8's t40 transition was the first + local experiment to beat its parent. +- Delivery builders matter. The new candidate fixes a real engine-layer bug + that let them survive in gun lines. +- Direct siege succeeds against undefended cores. Fortified cores need safe, + reactive handling, but local barrier/breacher proxies have not produced a + proven generalist gain. +- Long stalemates are congestion/economy problems. Reassigning all freed or late + builders to siege reduced collected titanium and lost. +- A single local responder, surgical turret counter-battery, soft recall, early + two-sieger pressure, and t40 economy transition are already in v8. + +## Rejected experiments from the 2026-08-02 cycle + +Keep these folders as evidence; do not stack or rename-and-repeat them. + +| Experiment | Key result | Why rejected | +|---|---|---| +| `exp_meta_reinforce` | 43%/168 vs v8, kills 65-80 | whole population converted; ~220 less Ti collected/game | +| `exp_meta_late_reinforce` | 45%/42, kills 16-20 | late production still lost ~140 Ti/game in screen | +| `exp_meta_gunner_defense` | 57%/168 vs rush, then 43%/336 vs v8 | combined steering+ammo was a specialist trade | +| `exp_meta_emergency_ammo` | 38%/42 vs v8 | low-HP conversion drains the race | +| `exp_meta_gunner_aim` | 50% screens vs v8 and rush | mechanically valid, no gain | +| `exp_prearmor` | 51%/336 vs v8; combined 56%/336 vs rush | neutral/noisy; six sites best, larger rings regress | +| `exp_meta_armor_rebuild` | 51%/168 vs prearmor, 47%/168 vs rush | healing opportunity cost cancels absorb benefit | +| `exp_meta_breacher` | 45% screen vs v8; +3 vs historical barrier fort | control already won 90%; no attributable gain | +| `exp_flank` | 44%/336 vs v8 | spread heuristic regressed conversion | +| `exp_trans_def` | 46%/336 vs v8, 45% vs rush | defense overriding transition pulls pressure away | +| larger prearmor (10/14) | 39-51% | extra denial actions are too expensive | + +Earlier measured negatives still stand: permanent guards, global hard recalls, +full-ring sealing, attack-gate removal, launcher highways, from-scratch bastion, +all-sieger allocation, and broad `attackBan` rewrites. + +## Architecture and store protocol + +`exp_trans_40` / `meta-generalist-v1` are mostly memoryless state scorers with +small per-unit caches and explicit roles. Each unit has its own `Player` object; +there is no persistent state across matches. + +| Slot | Use | +|---:|---| +| 0 | spawned builder count | +| 1-6 | map sharing | +| 7 | opening/exploration target | +| 8 | waller ID + 1 | +| 9-10 | two sieger IDs + 1 | +| 11 | enemy core position | +| 12 | soft recall flag | +| 13 | Sieger 2 temporary counter target | +| 14 | home defender ID + 1 | +| 15 | verified home-threat position | + +Writes are buffered until the next round. Entity IDs interleave across teams; +never infer roles from global ID thresholds. Use IDs returned by spawn and store +them explicitly. + +Core behavior: five opening builders/roles, resource-gated additional spawning, +ammo buffer, verified threat/recall, one nearby responder. Builder priority: +assigned home response; bounded wall duty; designated siege; state scorer; +passive wall. Sieger 2 counters a turret only when every direct core plan is +unsafe. Gunners preserve core-capable aim and now correctly target enemy +builders. + +## Engine and measurement gotchas + +- Local WSL venv: `~/.venvs/fcode`; activate it before tests/gates. +- Current local CLI: `fcode 2.3.2.dev29` staging. Production has started; update + and sync production maps before trusting new gates. +- Bot `random` is not seeded by engine `--seed`; identical serial games can + flip. Use 168 for clear deltas and 336 for close ones. +- Per-map gate rows are noisy and gate side labels are unreliable. Use aggregate + results and direct seat checks only. +- Known seat-locked maps can force outcomes between close bots. More seeds do + not repair deterministic seat locks. +- An uncaught exception permanently destroys the unit on dev26+; preserve the + top-level `try/except` armor. +- Turrets/buildings and builder bots occupy separate tile layers. Query both + `get_tile_building_id` and `get_tile_builder_bot_id`. +- Root and many experiment READMEs may be stale copies. Trust source hashes, + commit messages, replay traces, and gate CSV blocks. +- `bots/champion` is stale and can invert rankings. Gate against the current + frozen parent plus unrelated archetypes. + +## Commands + +From WSL: + +```bash +cd /mnt/c/Users/subodh/Downloads/fcode +source ~/.venvs/fcode/bin/activate +python tests/test_meta_builder_targeting.py +python gate.py meta-generalist-v1 exp_trans_40 8 quiet +python gate.py meta-generalist-v1 spar_rush 4 quiet +fcode run meta-generalist-v1 exp_trans_40 maps/duel.map26 --seed 1 --tle 10 --json +``` + +Before any new remote action: install/authenticate the production CLI, run +`fcode maps sync`, verify engine/map pool, and rerun the relevant controls. + +## Next steps + +1. Do not mutate `meta-generalist-v1`; branch experiments from it. +2. With explicit user approval only, authenticate production and upload the ZIP + as inactive. Do not activate. +3. Run multiple unranked series against diverse live opponents, not only Ijti. + Download every replay. Require nonnegative kills and evidence that enemy + delivery builders are actually being shot in more than one opponent family. +4. If production confirms the candidate, ask separately before root replacement + or activation. +5. Next local hypotheses should come from fresh production losses. Prefer source + correctness bugs or narrowly reactive observable-state fixes. Do not revisit + the rejected allocation, ammo, armor-rebuild, or breacher branches without + new multi-opponent evidence. + +No external action is authorized by this handoff. diff --git a/WORKFLOW.md b/WORKFLOW.md index c97cc48..d7724b4 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -10,8 +10,8 @@ promotions by data instead of vibes. main.py, ... <- historical root package; may differ from live bots/ champion/ <- frozen, hash-verified reference opponent - generalist-v2/ <- exact live-v89 source - generalist-v3/ <- next locally passing candidate + exp_trans_40/ <- shipped-v8 source lineage / current frozen parent + meta-generalist-v1/ <- current local frozen candidate / <- your experiment: a folder with main.py (+ any modules) maps/ <- the current 21-map league pool (.map26) gate.py <- the promotion gate @@ -69,8 +69,8 @@ them without reviving them under a new name. ## Making a change -1. Copy the exact frozen parent named in `HANDOFF.md`: - `cp -r bots/generalist-v2 bots/mybot`. +1. Copy the exact frozen candidate named in `HANDOFF.md`: + `cp -r bots/meta-generalist-v1 bots/mybot`. 2. Hack on `bots/mybot/`. 3. Sanity-run one game: `fcode run mybot champion maps/duel.map26 --seed 1 --tle 10` 4. Run the gate: `python gate.py mybot` @@ -119,14 +119,15 @@ rush. It found a hole 500+ games against our own lineage had completely hidden: | vs `spar_rush` | win rate | core kills | |---|---|---| -| `generalist-v3` (the live bot) | **39%** (CI 32-46) | 58-91 | -| `exp_early_siege` | 49-52% (two runs) | 70-78 / 79-71 | +| historical `generalist-v3` | **39%** (CI 32-46) | 58-91 | +| shipped-v8 source `exp_trans_40` | 55% (168) | 85-64 | +| `meta-generalist-v1` | 52% (168) | 81-64 | A candidate that only ever fights our own lineage is being measured against an archetype that no longer exists at the top of the ladder. Run both: ``` -python gate.py generalist-v3 8 quiet # 336 games, the real bar +python gate.py meta-generalist-v1 8 quiet # 336 games, local parent python gate.py spar_rush 4 quiet # does it survive a rush? ``` diff --git a/artifacts/meta/kfort_d6473f1.zip b/artifacts/meta/kfort_d6473f1.zip new file mode 100644 index 0000000000000000000000000000000000000000..f40dda754afdcc6754e8d7a3d602893bfc5424da GIT binary patch literal 30303 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
dE%4IL%veI`2A_$4v8fM2bMktuq zQbwpnBB)$7YM&qnvpsie^|Ws%9`W3PqjiGdkq=d?=4W}wC8Sw`ab19|w%?j>8jjA) zqere#b&QMD#}xg>1^)f}^&sjdKX|EV&G{h)i1m@CCTvb`<_I_GAQuO59vsNJ2M7#F zmgvJ=^hJ!4zF^kHWi~XEGhMp3HN~|DM)xlGegq2jI5odm#g0{>g;g=gu`8cxRzpco z6GGsEHTcU>sX!z<3k1u}=YBal-ka$wsriz+8(sWI{gXgH*xKRDfF?9ofy6or`H$rM zR}SWv2}em-H%9cK%R8e(cK5SNVL(nYCOh3^XLJitMR@i5os89#M^UqC1COX57e)Sp zy|crwG{+@;8HVlV;P-L9zaLo(C#rzJB#(s9yFCl&UkH2ibifm1o2>wlky{>0zMk< zM1l_!InB0{p-`s8mcv>q>a&`x$tJsjK8)}|(Pm#hQc02t&0e)=(#qHxil6UDqg(O* zRXsN8$^Ll5lfCAx@N4&S`EX$qg@2!36Yk*Z{Z|qaB6+L)-Q2zH?ooAl*nnoG{={sy zZKN3TC>3Z=pIl=RhDYw`Mrx+pkrD4FvA8j-1y{7J)h3RU=Qoxr1VNfRgIkiwM21YZ zpcYkMy~P-cOU7%oLx1Pt^pA&N>r2e&P`kTqH58%%mflJL@OJm2~@8F&$1bL*=RvssP+9-zr&M>s2+8HdjAe_or)(GKI`9L z5KcPfQ-oT{QLgxDZRgG%b4Cjgbc9OukvDfp*%Xvd6S7r@LDGgW^0tAd=m^5l2btz1 z$yka{m#%{MEg%83h{Q!ja!4sM=F1@%2sk8pgV~30e zahb{t(l8OUkZi3xA!5#hD^!z{f2Q~l1qA%E8KoboXHdc2ueM)dMnG-ke_G!ByM?W$ zqAN$_IK~{>ZJ*W!u(nkX6L*LmuC$tbLe7!8-@$j?f#4a|U%5T&Ys}*MS5}K2z)J6V z1RuWz*SDo(rm^34@hoky6nV%bt=MpYe(Mr|neg zgpEw|%E%ODZOE%~UpaO9&^kUZL7#{T06Cm&d>(8 zWG-D5N~UtvW-u`9zJ?u%I3WA&lKi$d@7aAN>ZZlMl8@rP0Kk5FacAN5eWq}5`0;fv zB=Z;w-=NI~<9B_(T1;H%1ROtiPwcVPDa^Q;W= z6a{w^LCY0GWwu40S67&CUAebF*eZH2pINeZ(^96_zE-6Dl|fkglt~YC^Xj{!x=i-n z-!*R5wG(RBZA&161G@@$I4}wsj78aXbS@1K;)S4!wLXg#ra|}yw^?l0I7@)sUf5C3 zStCvovmG}r$fb;~d!8bE$WQA6&NK-~omIoa8xcHc;}6pkFrY?rVo)5-nDt)h94;Rl zvS9{>$Upf@l(1noUn&zSXHzttg=4ehQncTMMOIOUOnTmKp5b-mjw!eY?3SYR%aM|5Cc^Is|o zsf>RvCKUiJruS+`3b+GeupwjvF;^#`HgG*$Y3j#x8Y1An_BZvhgmY?j~F zVe@@U-biVZHt4NoE3UR2x+TiSFP*KWO<%G&x-XuZp+695E-Wm9>k9 z$$D*!nsTdA0_t@6wQe|n%2fxQvgssvsH;Uys3Xv2$I;D6qXs~Xn}bfvjBfDfPqpf* zyBX8r!ePz5jHrS?F{M+!VW-nu9(VOPXt-QXp<4Xl0SJda1lqxQ-beBj3pUw`?4c0d z?3M4A=~xa}-D0Y{8VB=OeGLC%vLj&Jd~V~96FHgVB!knXf6Y(|Pv#+$^1r9$@%8cq z0vD$)l`jJuHKEDJT2%juji^+bMUL<275dqVS1amX$>vIkjh3qBmi<%@O$$UEF>U&v zc%zd&!t%9Tr9g?itzES9rkM2szonOpV6H5!TE{ZGn*dS$sW(RHK)gfML}gI@Po&&! zoitKVB`@hZM(}L8rLiYtUafW7(lB+jueh=_^N-}iuy%? z&hKC+A!r5x@49OJYA2k3r)~lti53I*t?|N57+}hjJ^3HcU|Q^eMpQxzn_qFA3b{W+ zr+RtJ7U-{^-r8Jb&r`r!lx5I^8Zng#^}>B7$EQ{fJz~@yIjZ_nwgmB*n7T8y@Ed#0 z5*f{KRZ|#Z_<07M)I@h!zV`Npug#|EoC=adh{|4=gR%d(_3e>rczoWGLgNWOS&dMr ztS2Erc}&9IoN0+1NINFh8JqH}^BUn39*CmcLy&@Y5AE+fQ!o8vlvQ4g1$Tgx*2N#u^o1L> zFS#V)CXo0co!>RVE%FWj$RW4)+{j}iqOgwFo{8Il&8hR>_$Zb|N}9V)r`a+r_DlU` z*&51KKCh%*aT=cPkRRT>Os^~r^_xsdyZ6MQ;kO z>zf}iNq(6~>+(zVSB~2;J(dZj<4FHAn9S~DL)V&hDS3BhO>${W&~KI$!{2I`D=H&p zm1RI=&aSd3p~BPW%wZ}957%QTGemd$eYS9vn(O+ke9>-M<7&dMWs6Xv;dW69_n9@O zPmXf zNIu<}z;_#aan%7VyqVgv6{Y6GMgyY2;xG0 z8(>3s9OG1%+^!iag#cMnx^l<3F4^ehwm;NF~~c#xaiQJp3YU$v2dI zqo7f?Gl9%)FeuFaCzgnJsYmKw2v5r=sDVrx|>@dHOEt2JUD^6sPCk4z7N|hO{vdc!C{A zOX$`90)v#5NCl;p^3J(b#7q>z%a$IFra1j#Ro&+@T%he9U|zne6)svOE|6kbb&mF- zl%0*!%j;etQqbDSNZzx>4glo-a z=Y8yZlPW#-1A38FNh7MN+XuPx+f zU#|#Rwa~nl8P2q%*rNG$O+s9agT=Xn1y!`qMsHkD@Kw&`6LjMYkz~tSk+(w&YZv(z z$NhEud12K;?0(Wx$JcG6a-?piuPJ#}xe><0uD!;IXM#8eUX+-0G|;H-&j;xRO7I>X z@5aoBoK)Sw{tHi<>??%G#z@lC9;=>7VXsH{eW2OrZh2#6G7) zA|F`!K}5Jp)p$~w(LCczC}0>U9V$ZM8avgk4zA)%AU$_Zl=kM93^{bpM2S8wjj<5F zqD=~whpq|m{Ie43T>7BAYN{YwJK3Oc#$vxCIhT6r zFt)gtdrSJ93@TL4eLosSlV$`4C3Eq7qy*P&is;SV$Y-N@uefSG0s0Rd{^;S{ep``L ztoI+I$Vs-C`_lB<`Dy z$W;%Q`L!lm?uNavzrhT*Cfd(|wq3L_oup48o6OBL{>+s)cEDx=G$^am*tAxd;VSS4 zqBxKdv*oHhX|$Zkc0z!9av2Ns41>a)%6fd!plMB9!ylLwgO}RbQrbI+tLYIMww0Ox z5+*8xn$v>P*HEE9DKUUMO|4d>Zb|F{wqWzXO%Wz2RaoG)S%pQZf7$*NzdV6|xEQ4A zpa>le0(c@DFD)J5sm*#a?%k0F>@bTvMsP|+DQtmP z!_myBV54>APs|1GXR8VIsc9^6ieprpW>Bou2CyP52BkzC-6<}XCGC2~R?RN6U6S=A zvdW$a=2Vg5^=FgS`T=i7416))Dku_zMepr+sr-GA5cSH23T``%$MM_S7mX0{( zv8YB^Qa1xd6?``ea}vkpSb5;!jkO=g5=kCTeKLq#_6w|KpZ?r}X*0ZVoVZ@pY&E$PO-mtNZ`9{IRWF>H z$y;af0N320k5?)ZUU-z+p3O(6>RXH(I&qt7G?RmP8@PwaD;-?T?J&LA<846)U+{yU zGo@OF%$q9Zs6U}yC^+V}6WRd$fuN3Vgn85#AgeB%jBb_CGYD>^u$pkC#n5R(T5<>3 zBRJgE%r!9k9k=jee0}{L!tLEEV89hTjesB#Q=-Lf$w8kLEWrW88i4F#tutZk23=l; z6F5Q1R(4c@zbd$i24MGEBuDDVc7iXL-n6K$ePH0_5|rrV*CBNz%&ocq+9pL{p#7A8 z^XSzC?|gjhUF^KJb=cVTv?=;f8-2#JSYAeb6=IsdX?%9YI^l4|8=bGVA&;>)jNC?4 z^YuvwMJO@&qp)GWkh-=nfXvxU;do|Qzu5WTH@w!mYT5tPasX(q1uB~Z*LsDk$&vXh z?{0JlXb}Os9n1F(kS!amLI0r%Bl8Bi{f$KI4 zZ{4Z*m$nz`2HKj4CGN5HEr2w@;Vr;20gjbtt`V4pp$*#N$3#zXykEHuI|9r2w!Ee; zX6*YDcW$&SnG!t(!>Ykm61fou2P~rXTWs~%hMfOGBFVds2Xl8pAR2GmD%4hJQzww% zzClFG@Og%{YEB|zMA^qkmI95hLd~*nLA<$H1C^;#&F#(}?(rrkt?ab!$5@J?F~A^; z3P~EflRx~qxKf*n(HRGX9S@U*qU$-2U^P{OK%8c)_Ihl(yXNY_#v zTG~b$_XcFB0ZL`Y{7}a66r5!TpPkoC*#X~QS;AnLsWN>F5VSA`)+70#uj*m?fUV_w z=4+v9eY4JHYRz_*dEPW>HMh~;xOv7UFR*zX zDSX>t*w}eim3{`v8B`2VDpLg`dhe8Ah5zfBVp8uoRcDAJEfO z!sr%=8)(m0f{K$OdqEOtq{Riz{kNF;^f<91t9uF2?UDM3w#=)EVYmctVw!**qvLt#G)CCO|;P<}D*{Tj(Glr1UzVr&Xla4EAMrU48O z<0_qJMt8Czwd;Ww{UP3Xpp2A#g+4^P$S^8i3K5@w?t7INnxZZKF87JI2tcjnv4_Ly zrfiI&rzQY|QEl1F$W|)0C4B;S0);m zwH4x2?OZ6X60F+w)_{!o2=@fMP(+L>m1lH_o6}uRzfzP$HN?Xs9!8dlwa4JVT7&u~ zl`gY+RyeGl^ME>7BQ4b<3rVvpn9TP^#)Dwyh6y?qqz2nO_Uzp~gJAwwxIo7!5!HzJ z5g5#}MQ`ZeMIFg9#uhzZHSkKJc)4(;q{E--zZT^i4!Xlsmp(P_P`RoHbMTzk%DMv* z?&*&(+BjQWa0(d#Jk7>4@_Y&myNK@jO!5^e$)}?_ds62+Zx5h2trOqynyoE+vldqq z5+EsL^h-K8$xD^KVBD;8=^R*pl?b$IQ5DP5}(;b;!0iO4Z$Vkgg5sn zG{KJ3t2w-;_f?~Q{_8Ddt_&c%CipCwx2W2zH4QVx4xUlHe%;*UTExWh{d{$7j1?5!=kDV6SOA&u!`kw zTdPa)4z}48M9KSUWhqRlpN|=sjIsMyvEH8yD{PuvU4UySrN5`a%cbhUXAAlp_5D<- z|F-VW;dJ1#y+E8Cw9-E3#UWA4U5T=()2z{!G18B0gWqoK{zqCRJeaG?aI#fv9Opr{ zh3@KXobtzzo|3QqWV3>-OS+~5UcY*on+ovu+^gX9G_%R-k)keYRz0yQis!6XKnFgT zx5!dUY45*Y;FsTQb*oEP^T&8C#_m((9m0z_q==DE8FK`119>yU22>cv?(Ue$H1n&( zN*LFIht=lysr<#Y4R{AsDhD6F-DSvSq7z1oMI+RY(^mj;3s~nM&k8CJ3SPY?FfLSy z8o*7@1IW=vlNJJp-FK@bSJ7%_HFQ|x8}03$8#=)o@$7h>wTi1PyPzxh6=pJqW_G*3TktTC<>q_IeeSq3h}Kf% z*Efg4pqL*P*#GF(!{#J_k8xv?4RP=OQZA1M)r%$cWlwo4;7%y}5xUQd)FlhJ7wx@; zTh&yo^j079LtVby20COZ4Wx{%5&enEeergtpt=O@nLR2UD-muXvYv1VFfO&63Ro;q|+3{@5o z_RNdR>t*OkCclx7o%&)W37+OCA-74WfXD-Qwy|4|<(B%LF$%~*7>51#bHabGS$4)L z!O?)VUIuMF&`dJHKlJl;tbzZ1kwieUWVE}DIgZJV^rpX zpl&+hmUD)0RQDfoZ7GMR!N$^g+KeDyx{(5_JBXD@xk8XhvA(>MNA*`s_6c{@@YLs+ zp-;(m9>Tiq&W{J4=EOzcozzxk-k%-c)C0AU)H`+3;*v>W6QPfu?lw68WPm}GXn=o$ zNCQS6?t5MhBNm)*2dppggvLzRZWNwt(#KntceEm+xzRQrJHpl|q6Jh>xWo#$PG{n^ zIe%9ZQNNyBr#C3gNzo)-RA`l!B?D3E?PbV8lSADXrU!L_$dZOrgdv1x zXMEKLhMIxl?wI~qLrx=|pZd|}Fyry{5!m>0K471Y2+I6b!DYiVa|^~=oA?piMp1Oc zQE5fpE|B!Hd47H=KrmvU+l6aMSrI=eSExw?9Cvf5K;u4=Gl(TG(zbsF|%8RXGeDI7D zPfzb)*L)xMaMX#UgF6GP_qLC0p;Az?-~XwfcJg_IR{?L@9=^xYN)R(++|=_&%_7yMO;QQm%V&~9TpXKcVCH3h{^1u z*?{;ITEQI~plKL>1WSo5U91W!qq;V6+xkebw%7Wed3^2D{(ng$7~R9xgGj}Ox3_<*1-SYf;GtQ8O>C+Oyc_Qz~ zdvw*=J&f+ZR^YGhqYq}N6B5oBkgna?TDH>4*R)VSWSbn}ew~EeN$#Ix*4~M_k8iga z3CHx>KVDlZ;(`%Gh#@vo|lGb7r zmp5`XyvU^ZuXb+{NW62qGqC~3nsUVkm7h$<6_!rN)~f^CmGKhD}xuxsXyjN+iM%< zhq6#QGs;zBXn7-GJfbX92x8B(&=2TkH-lmAyxmFF-t<^aQBT_kd7IFV5ghJG>vKqE zpzT`Qzf1C!__sC|>_zx4NRIan9Jyl&1381z<~HoiEec&LFd4MTFhXA{$R3wDq*>uB zWr;p3n2|_4V=L0(Z-K(iX$Cew5wZ zoNPmZ@9*>b&n7iHPJp|U_xi-Fm z4dy&+BhD{;z~a0m&acELx>e-*cM`e3X>!8Ul=$i&0^R%Wy@Esp<)e${ZXC4kwRK)E zn29RUDw}eiRq#mOHlYk}YW1$9)z2W-4hs;_&QfP6*^>D4=iK5l<}D@`L_*%lz|`U0 zBJ@m^+wCa*Zr{(jF^jJq{-I54B;kIIZ0TQ_Qi@GC$4{@)mQZGEdC^e^ljf~Ws<$oP^lsUCT(ZMZHH2CX^F0l8)T)Zl2CZv`)Q0x4`jU`YG z4(0mR4^aM; zJG|En7FJFn9E|JQl&u=&&f`BXrAEMCUoc&HEsMAqZrpR-cF6nP2@(dCv zZT+*{7)z|m4Sk|HmP|&zWW{DO(mAUN9vD^CSU^3h9?TSq=C)t0Ar;}N6-W7~oLps4 ztH;h|!x{S&EABtk^CNZ`bNz-qz}*&AD8Mruc)}jK6sKukK#huwGRe{W<8wvJO&#s` zsacd*Iw3_!_Z&E-3wL;xIH-V9Qq$Nda#SfEK&_G(Y@=*wqmP4AV7S>9801B9FR-2d z0uFZC)9Yc<)FjkeFQV8VTWwr>QKLg!TDcHd{U^}*%FfKXmY0?rf%KQuA_tp)eM_+h zyRX#M`l22V1(&Dx`BH&ol}H1nbFe8+x031EWjn0_d>Jr5@}O<;*D!xa>rPt{ysPw~ zYy_|HZH)fAl1Zm}m3=D#{S0RcITH`iV-=DI&9Xhg&2lk_lTg=)!wKyFJj%~ulVERC zID(Lm17ppN8cU0ab@ao6gwMaj=Oa-;`A&GoAw6W(0RwUOhc|uzm&KrwMy8zQsE2XH zni{l_J;{&> zz4f)iU}5svy@*bSx=^Jc!lBh{qXKJyt(ki{r>!F5qQqs|dC5ZCQ= z+RKOWc3C5g4rfq17yqajoB*HQ&La_&MGCqP5>UI1qT#!;u793>H36A+xoF%u@Amc! z*Ugy&#G*l(5xG@fI?N>J0xq3Y14UF5dLWXnHd-ei;+}zOx2Kb6(F{Z3W0Lix5?uyQ z*NP`+XIp|5-Nxiwn>pDOAfkT$I&rZRWxn_K#{eJIf8~doy55?gy<&4Ke3`OI>fWIK z7by&FO_qEd9Tg5zdmjYA3T=oGnOL)qzLy^)m9(_UTpU(LQ5ctrayE3&&_o$D&K1@M}coL&{(7*;Di`LcIj&0USLE` z#tJkguTVhv*f7nry3OM@90AuJG-fhETIvM|KJDAzt%Xizj+4UF>ZgLI^EqPuq{KSY zHXgmmPZg0GfPgVj=l!Iw5wp7;?N_e#L7m46J5nqJP_ZCmHo{{g%+HGLrFf zHUhzU$= z{ikB|^_~`3GuCK0`e@0^Tu{Y;ZZ=cuy^adOAB>N+8`5aF^Yr=NCWfp-m*oq-0^B>j z*rW;E^L?Icy=@MeUKPYwIfF|n?t~f+EdWllQ7W2s0qL zAmMX6Ud+(mBOjnA@sC@L9{0bUSo`D6R8nDsMIa*Y_0(cPb{K=PYrStqdD++pb_`Am0>6((qVvKy&{cZ4K#W>ajd+psI+z`4{vf`GHvV^rDN z^FOG?_VsrilosMkDKJW)k6H5!s6=>*YH|lMb`_;-e>hEZ zi%{>s1gy54NuHZ3yI71@@3{s@el$=%TcA`I{IXR#wSTASed+yA1@Hf*$JJB%LS0AdikSJs~npC2>P9AvTdncj0w)OoNP zj@wSsxK3N4VSucoDY}ZVJ;>VVOjA7rmM`Qf4@}DO?6qhUU)%lT75pu@tD;owlxSqt#oi z%(}HJui5O{d-_|mzQv3rsh2%3{*0Enb+;7`1nTT$Ka}(V}CfS#tD=ttj@4Nl; zVb<#{I$w`R+uPkm7<->LXk(wrl~pT{;bF8Gzx`CiPdMG;rP315-U#a+NRR|zTCVQL z>;7YR_xHPU*gh6hp1BG;1&8(+y&C0;3MCbtk;xGAXSt017K6ag^ZA8t;`)kyEn%a# zf(s534@C{!Y~=Wum z4VJX!khX?H+6_innRORFR9sP-*!%nCVmVChwjAf|d$dolr|0A82hSV$`ulx|{uVM1 znI!^)bKm>^mM`F~7IajX+vj1(EAXdD9&R5BgW(Z6MCMF_1+9{evUqRQ5GTb&69)0x zN*NvuY6kts6QcL+JV|6Qt?M!*J;6LxZ@8-wn5QUUPfGp)><-giW_RwCRjzKe?WJ)|I1g(=E5ofMD18mj1>^oa1TgofONVR;OsMi%u}Ip`~#4!tAMg6?wIOsN0K@A^}8 z?f_x^K=Mne$+vzHoI)wwr$&Xb=kIU+qSxF+ve@> z?6|S_T4$e}k@H_Z5i?>&W{#2X$k)j}@Kz4WKqSG|T{m6}X}T?2pi6s-NB>fKS8%a~%agJ)2Em&0J z(JSK$_sT+BL~>%QlgJz<5O0hrEdG`YOsQX4CjS1Vsz;J#B~BibgI zxXhRdc}3^`k*I`nXj0E7`G?|qf8^cbdS`f@@+{dX$c=*;n9L;wivV>+(N;kgaE4n? zE#)P*`iT7wihMnw#HocWI@dpswFvB1T_1q&oLN#yu=!e@v>fQ+;vw^o4Xc162)}I@ z<_ZYf9o6{d(ui#&*l6Z~&cF_jfEU|nDbz^H^7}1GC~Ictx(K$x`Fi%CW!r^;1U^D| z)v~t)Kk1v}(-1m*!rn^*Ytk8ByRJ-;{=DPdGd*EC;E^PaAliGzqdfH;e*=hxQacHg zOp!h8jVa^4)R*HxDU50;t90C1D8QIHmXg4<=!l^n*D@G~e0oiQ)qf8-)di^sFT@>X zC3Fa={-M8=E@=u&81PC{*Q7%WiZG+HvROixu^8ZNtRGD$UpHUxLKIuX3X1qsVTNp~ z&i|f3@{`q_rNwV!dc=-piQbjbKsKxdV{T!AZsWUG5uXxSq)66v=9r8UzWUoSp?MYeXbROTdo7J#v$NXya;Wi$v;7F zFa5A=LCPM}03k;kxct=1K;XEUYiom|(*g)ztDYE!uB(6=>NcxG)r+v=(ar7v_C#yv zrDwN-wAtbcPa_CAx;IZ#8GNHGrdj=ZA6wVUWzGNAG6Jp+!0#HHZ2EwCFW*C)sN6L> zIH_p$&xVeTI>fuqFFwNHYXw5udEAuadgeSdmVI-L>Pom-9Oa5U)R7__EqJ1ekdS)o zNu6!6$g2F1_%SF0cq|2<3w_uSHXiXZYxEHTMm(5kQl)ekQgj({V`s{m@ubYBTAMZ% z*k!|T1N`^SM<(|vK>Z+4cXRXMY>{2wBMrf~O-ilIxJ!;qet&1|%e&3<`kOu;213$n z7x3%weo84J8=u()nTV44W$TR=ZH7rr&m@4O^MVxgNRqvEHVa<=(+PF^oJfUf`$ROe zEdO@yNnT=MwkFXz3kyMHX$v0v*EEMvN#9Z2K0`w8$QCUOyg!Dgiwh%SKyhnuONiJd z)Gu?!wDXfHE(;B+Red;Jm|SZ|jOgr;d$%4I-pbJpQm!=5zoF=V!CnB-^MLu(R5JGO zsO_e)@yeg_Uld9y4!2EAzd(8;C$q&8%-hsAqsjW5v6b!<IWXvQA4W7Etih!xjiX#S^lW+CI2$3*!Jc2VuFp2P>SR(yw%9)G%YxJaT3%3}QY@g9dpNhZF-$?K4Bgn)Bko3&)3`SI zU>8r{d{DRQ*KWid-ZlQL6G*>9m`s12REFEZ_$f8tm=B)}O~~p6B;MaTO(K=;5rfcg*%Xw`5Nl&#@tr5#723rr%nifqqQgfAt=eN@o`O=0h0~^??-xNND z{NWW2Vb8Kbv*c^^*^~JyH?1pVDpXBo*Y0_RgCu(m@7zlh`45ig&Go=-k+XpFNW3OV zMV%88=0JNr*FM!W3D7q^5S%AD>6KzjGd^vd)KdimE?eh!T+bz_t4ijdK!%^61K=-T zg(fT%buAuj#^_4JqFJ=fm-!~O&B1E=-W1|XI5<()20g7}R1B9OkO>%cNy={+E4FeW zz-W4W?M%mrUaKMNTXfxDTt1d#CLY0mXvGMPkah3wVaxik&nc6loOXB8ag`s4YLU)Cd=n4*G%_0PFU;(k-(()D@l1N6*eq<**qE;F}%0xmj1i`;s7adCY4Z50o0v$1pAy$LuJWjrML@rZtk z+mh!rN+#9XBug(!DZ9CwN|VM-Co2A!h(SSvIB3p{+SwtA%qbHm-l2c&EMN#yK4zng z)y{|;d90;crAlq6fhe{{shhMKsPEBakrd%@Z zZW`o(!#|tCNfiZ^T8cvY&W}K6`p4Kw=8QQ_^sPJdSB54SZ5?|d^r@7UWlled5>R`_ zAm+fu7+yaN16{z^?t}d#z{;=I7TVYs_wFJ-e6~~;2MRfMJ+8`pr$;4*k*USCeN|$a zYFiFs60|(o=x$d@Pn2JJ!6c{!xOT@PM-D6BgkN_hW{6i)D}ySYw>Z;SI~_=elVNYH zZilC<)%8iohja6VT3-Sb;{fs>s+uGcl5Gl@3I+m+*br?^kXY-TesIRYkMl>$&cYzHGj>FYM=)4kxw5G5d1&K>noQYNjs*#) z@dJcEVV-i~GmqC!vvVW$&Z%UJO*-AY1llZe_Zw%e+{TNUKKQB9$W%x3PdPsBqlBS~ z6vUW$MtnMJl1kUsaFyXoeX>uR#)+ePtQmQKwIxf#0BX3atQDD@pfV?Mr@93fjLMMv zHKwY1RXSmdE5|R+dU@GVdSnyd1YRw4jbO(2EQ}_@!_5ye86;)IRBz#N*)3AW?a<07 z{NPla0+em+gHM8=9L77|gEPP!9HA=E-JS-*@)VGq^D=(Yf}p+;gPwI+vM3pg4>Qq$ z&*IgRW&_^_%cn7E-(w4`O8z=AqIU*MvSfYr@{FcAip>IoYuxG7ZyQ39r;?HIG)0k& zX!(Gks?X@uwXKJng*`Q#AJCU#^%6hJ*uR*+3o)1fW=`4F+t@6A4UUKaB(D=*oCzP?3|Y=JMLlC%NAR>3-1fr^q$Bat@tB-jxg z$!2w8jhtQO41Pg}gD@%GrNZWAKf~cI#zan|q5OKu?L*6?E<(C)e*woOYYjrl6dE$o z-os}wzPkcK?910mivaVx-^;w3+P~+XW77`|NEEOM#ZC%lvNzj%K>+!Ctl7s<6j1)W zzQ(PIv00ea6Sd>JP73gl|sgp#acy8tJSEEi>f+WJ{flVgnM%E4?quf5^cR9Sv2xoJeFKi6av{Hfm5 zA&=JMaf~LjlH7h3ZqlBhWzu4mrDC)js)i7fBBH+0NYiR=5i4BlU>{yOJxGJL2V05; zdpTl$vsbZ!o}D&I#+z%f#(4zvR5|yIMyTS!szdD?H2%%Hm=5=;|H4Oid|U z;|yUMyLbLk5VeveOwlXAAElh1=?Sg7IiiKtcJr`mq`Mh2@JtE1e_q@v2*STxcqmT_ z_>yfHzD&#z4qZ6V<{muhkVRqAz~W^B@NQbzcE*??3ULEb&WTWNfH(KAM5B?}y(at<={_s zfS%FqSmR!S4npIk-u1k=9PYT+MOAcX^+g_y_^V?P*a3|miNaiipMrqR<26?hOP2rI|k;FFhLvwJXyN8C$#WC`0* zlN(YxskSv2AIgJPm(MJwYKWU`;OsY$&as&vVcZio5Caj6dq?xfParmzJuk)VEMX7#ik8|x0@Y%lL#zg0Ko@U3O+BWPYRycXV@GIYX$V@O!DZY3ta zX7m6dZ%-A+M798I@V#)ETV7v!9J8YIpb$1DGJ&tk8Zo$ntSj|>uA6H9E5YGq) zVERosFQCmg-gP+bjy5TKAg($_N)%TN5L#>i;n2%nHev}#6vt$@|(wuaom8m{Bw z=-D!!i&t?cjuF|LGUj?vXQ^M-pSX@cs%`KhJ3*$p@vjoX>_@lEf*ftNP$`=5NVw+T zxq71`MM2`Yyx{$L>d2h2DSD<&_2vn;)9ct_(Aw$_$iD+a;X#`ZYy=m>o+fz79Z1Et zI5k5Rr@VHlebH|{g*L^zCX_kXIDy@wTgc>{0in)B(5>kVwsb?>zR5+Qa>XtNM)z!t z2c@oV>!BI4$SbUFyIQjw9yr>Z4E!@mbYnKuL5oyCnBo*PYH$llh17H1#luiw0iORb zS~Lc9&IXfRbAyMx)2KT^nt2;_8`Z}g$vR15cqYXLKR``UC%aJ>yqU{VE=DtS6}g}L zb^zQTG>7D4Cr2TExf#G=yl0m;o(WO3^*v;#w8zDjgPR)E9cWhdI|R{LcHo(10m6$R zs988&Rj^$xZC~U*wk&%cc&E`5i~oTXoEjXI>IeR!Dy0nX*ZH7j^?i2NOH~V2clQCN z9l<54@QZCA#^ozi)8$G(JfH!#4WH%R`Sv#;4EsfhFBs^m*h+1o?!t}pg^vvvZ-3@^ z>B~gs4>BkKN%6l)4|zYl9;>FqDwp8xU&XeFUog#oGjbEcH>jU zH2#g=p#qu(ukONFc$72v88y;1i#}%s@^a9~J3@>yf7w7#^Uj{tikHBxTfsW|Cl{Yy zXs?hcpB|aRZuGPdUhO0iovTWV8qe;}<%-@geEQcrRpqls_puAsfgE|fegctb@E^9v z+B+_Y)0#i*ryx(W`b1by_w++B!uizF-JiH-I|x)68E(B7Ha48)-4=3$jlS_Q_FSnJ zEZS~P6G6lL&u6g^esbr0cl>868fZtmMd=4%e&^QVz4{m{^mDY4k`74-&`oh%5Ue@s z71du2IUmIE@4-oi7RLLtlL9yWXCO`1Lp>^-p#$n|z0HW;puLTYAB4s&W52$XU0HR|c8YQ#RRdzH_x6r&4yF|ZEz2q=+ zPbZ|*xGL!MtSe6ESmAJUw6nWWAt27#st=?4E#RG47l%${vdo1irAKntc;lU8OpO}J z4Z-8p)Y;g{^a85&CP9G^5z^m1rjF3)7V?;wMiZ#jM}k=}O?Io)kS4PR{1J$n`@?Q% zCxQf-4t9MlOC6}?7k@~J3@?Re2Ivot`H^7aYfs$R%OqFizopccM*5{}&!ImV*BMAB z$e_<=fi%fq^n48`2dYKVhZc(oZ#PVIMBNT{Z@~jExceWTN(&L3sjNDlpE=C*FNk$9`LqR8acg90^)M)Lny_}e{o_!*z{7L z*^@yZobFVAB!y^eOQQ(#MLQ>e6954NbZaXdNPG|-#OT0h*K}Nsfj({&$yrzt7azY7 zg7(m>hdTtiwFjq;Sc`^Ep92d}Y#A_)3cZmed7!aiUdTWqbBlyB(S2*40w4>{5}Nf4 zh{wvHad!0v8e5-F-AI^GKg?SDn{Vjncbl({W1u-84oE$O^p$F6Q;j<3fJ7)1|d95a*kV+wcBTQ3lT5wk$s`1ByKa5o^~P+HEziy7|z| zZ0%|CX2Fane6uEAW$aF_UP5rWWF7I{#C($Omg1@jC~gD-dyg^W0vHHob~n?V-pn6n zeW#Os$Iu^NZjQfM|JoVao;NrUA=*8~KA^bWjhB;?MyZqgL-JKl!$*&aq(z1v5MUn& zqJ}2O&glaT?MazLYNPxxyJC1aCNA(z@{?M?Z;na?<1tO@zPfplB@I*D04nzZ(T+|o z+c7{Hi+JQ>7P(FOQOJFMPB?a5cP9NxNDREqM7f`45!^(qb04zz;F3Hz9Q}()RbB$6(R2o#sLddepi#$H;E!8 zqUBcrzJcNIMzicjGy{GtD&sY>Y1KTrX7Qtd=`@FG5ul(f%EATO(C!?UrrzswBI!-8 zfOQ-;=^+Vj5N0=w4ouP{f}UXtF;^ac1WkHM_a#+%vc=70xFJVLL}3|pTGaS3G$0?N zbp7jgz{qJOn7qDFN2oI)`0^JDg)rU!WNJ{Y*$Ov`e5MT;LLWN^JKVr`1!90K< zcGC~Lmo@Shzvdn274Z@RmOiq_rslQ?+&Q2kMTiO?k`s$<@WuUl=s{}uq`e?62~_L# zhF>tU5HZvUv%jrEM2<3&;VnZV4fvzV3olP__G{50YD2~q@&_E+FA8PUPb8@63({Lb z&#WNQ43AM6V*3y%J730H#EoVr;fstI!t80$>;=6Zx5fm#i*aiUj&uyDAqrT%Jv}E#Q0aN@~=JkgBl%>Qj zw74ed=Hy@#r0ZyH8M!2G$Y8SI`Ne`F{Q#$HhxUh49#CdF_(o)hEY5QIg-&0GvJ`s9 z{>`sp?O;{(VK*Ka?g%5hm5~*Zq;bF2y$48%WyBN!Z0Av^NV!dfUhA|}!q6To$2ruk z%A6nMOELwEv6cBgSN6Hsf!FJyu9SBL-PQUVT0DdyNn-}m0+0j)Sd_?v4AiY!>h~R# zE1EPZ%Sf;qdy|X6Zok$V?|;jtM39THH*{KSA>RA=qh`j3I-^kCVL(wVa#GAVqhMl0 z|4?8TFaHuwT6U&pShCNoLm=k)C2w*@)+T47oG-ape?$qvEvt6kvS!1x*^0D@N*CL5Y|8drfN3@Ie8s7U38Y zk`w+ASr!37k~+<91ZOzEu|!IaQW0ECYSZbCFte;u6I--g<{qsmk!8Qz2J_96J<_Bf z0KHroJu=VGY2AX^AB7(iP5+!e<``C384(w&7d3MT%(F#>r!xvr0COb&7%V0Hs3O8b zGK4ASpFdl>jpgy0717@lsN8_uQ`aDDWJ|NH4y}w87|?g7;}1RQAVF zUmv+Y1D_~lamo}OBXED$gv-FeAJqqGD~nZ7*VD(!ca+U1*%UkcLaRi8g+C}Nmg+cV zPH6icw}*&oVkZlXD?BEq`B+wG0u(X#Ul1>bZ!v6#-E#0#sWjx95Bn-Q$SuJtxqL}+ z!j{(2p7Gplw%y=!5d;>bVqNZU_PnDj@wBPPKdT&E36AM0Gq4a%`TXrX5L6Len0 zGTUGy8D|XIY$JeD*}Mj)voYS-O^tm>&`UogUyaUaP5Ck0wa!S6eSLTr!wOk zX1z^vs?I`tu=3y`)1sK?ZQ?e@@nSGMv0?nA;3o>M3t z>jE7)HR6frw$f1r4nkCxs{?J$5|vZA8=_Ke*Srrb3jUS^trG--p(Z@qlV!2R0>eY3 zoq`DzN#A9^b2|zX4$w<6MQICV&IB&5I*u3FZH`e6&gP#iO(U)_*Exy&S+qZiPnYr1 zKQO$)l9IQvayI%ED0-biyJSa>D*vmrhKx$M^lued_HL6Ih^Srt{B)TMN~vfJR+63~yw5Dqel=E-a5Aw_ zJT4Kx3iMVWK_B(y;N>f7UQCRIOS$O|g69ONhvS|YH3@`Rv9l#fWl-gcKGm(WapKb? z7OOm^k4>1iRa|YNP2jZJ0js70PS15ix0LrW9AAp&*qWIKN!cWH!e)N0(4G<+;?`5N z2QKK(YhoOje?A&VUK4h?2tw@@ML7LnTLHpE3|b_wv!*1APP{gGeCEZQ193st#u~6P z_gyhJ@^s&amQ;}|SQCV8Yx9-=E!3T`dZ;j)9QwxUAhofRI^&e2xw;QZxWaP3^Ayzc z6RwJH#?ccJ1Hf|XMG<23qLLy=?MkjXbXpYfq&R6J@zHyAEz_H*T%OSCfAzczLD zRz;`8EqhiWVlnOVwT96hdSoX@>zAf8&1N+Uvw*dD+&Cxp^l*N7fB)#9=yKE+vhuCw zmR}qv!!}(FAAjR{j(i^+V6VbXaqqX;PnIvZST-VD#aKuBIx7Hc(p~yFV++7At58Ub0 zg}zwz(nBSuaisb!*!7XLMM@tz0?G?g#V-LVoh76oLnK3p)rH3-_Nd5zwb9x;tQ_$j zh`#bQYQcJB4a<8s2xBt14SAUPaDL!GS{6gkIOUVR$5d$F`%xd5quE`a4($(@7OYAZ zOmfh)DRhlJ;!s6A^B0-9=`lE+CpRg;)6e4(s#`rJT zA_|#%P;DvtxjH<2ri)Vqtm(d%@E5itG30zZLzmluH1+;!^0NXr<>swnIR4t~m#+6_ zdtps_9h$ly|LLR^%hO!|7kylAv9HzWUz{VNbw|ZExDNPnID$aMwU-O_Q^X;zhjl*? z@_zcGf1(2CwH*J*`B~{sF(_*65E?LUo|g-GNoosW-BgYpZ>cyrISoq{%*m%0jH2k7 zj9}t~z`m-iuAGyO0PLtmd63|Mue{)p?+aYAdlfewMV9IAOO}~SS}F61zLHCF<4k^m z|1*AsbH(pu1_uBz#Q^xXzkt1gv-$rF9<)wxess%!2Fu7Jd}IWH!I40T|3)(8 zL%2{!#TCLA?uk{FH()oM@Cw&a% zPXtW`X$jtQBpMGTeQ&Q4C}BzEsN&7;P9F~+4?S{%j;pe0+QFLlNttkxf^SyOi?gW2 zhYcx;0QW1z>>Ceni}l|=u$HS(#xN!%EfMY?jUB%hpALUprKMH?fQ2SQU&9TErjgQ4 z3v*ODssd3-wE(k_Yg&NNDa5E~126hd=tSNm4bheRVg-{G6IAX#R zU@gOk#n%7u;fy-ul?)jzOl%WLGL=H|UTPvS-y!kJ#i8($+pwmYrR%M?7n{%(iuWRF zDf9@(v-L>`(r_twWGn@gGyh<%2RLbkgeaG>itLk)UuC!wXu8lu%lqi!% z(@es0)(5W@Xaso=E^ngUejMR)gGzq7rY|R?USfoZb$c?!+#MVMz$rJ+`;aC=}jGDOvzfAJ2ema;EBi6wdr^SK*Na%``{?52lck3=?7+w5Yr6X>XaGhIjrU}JJc3U zAqyp`-x2@hvya?-*X(PhS|SrnEVpKf<3V9&vmkk%TPQ~rmvvL0Lx*n@qba@xwyx?ewM7HKW7lnuu6jKkrd7~ZIh&P3~UQv5>c2f;Dv>BQpO|=&Bp6--8>x$4V7|T28!f7I2@KsROaW`)-R6RF zt0j23%@XVeyC*Mn3%Wc|QC-)lZBaGXPqTo!f(ayQJav1ZCeiVDr9T{zQyt71Ba) z22_eA=uw|p@>l_e3_Ll>(1s7%=A~u!MtCYoe2P1ZYObK%Zjew3<)%`<|5yQi+^)E$ zeb|oIUlh{a+pShU(l0y>e0RH5TlNj)@oqc^v8XsD-BXDSq{KgX3vJiech_;U*@M@_ zVUk?PX5XdFM7+%H%dN1Dwck?xyt@_6>epG(fbm?UE@X|yT7@@opc@s( zO>)LRis_uwm)Y&~Ls~1Tt1sptl#C8&Cm!#Y zi>;(g?h8LC0Kg{<007EAlePBN2DT>u&(PTkJq3y#lVPHuVKnF&cxm*R)_=W+~0%l`1zuGL7Em@^)sJ`q4* z7neK7tf>|FZ4Z`>P*cMnLW5v|Mc&TJ3US8faZFkOp1ES>NE!)SCjvt3k5ef1ilN*m zs4n*)%_`PTp*V&P_Wk`p#~cJmkv$PIL}!D!&a77mc|~2&738m7=BlsoIRjvj7gHNNqzg- z5H%(nGTekt)trE{T{8#qSDsHIps7>GAT}E7Hk9+RH1akzAG%v3|1@M3_&0Wp9dDuzoo)9sp-RiP zp$6KVi==IkXEG7lJ4NTPRH}KK2;}rrv-++UAQ)| zqpzm@tU0W=gY16#rgjqMTVa>y5^JT>#GS_vH>cmwECAo26P^aMsiMl*P{pPU>3NYW zG!m@%fEb#WZZDP5Md}ug2l<&FT+`)CDEwyV7H{GdEN_l5qsuei#~?U_Xc~G3EMW}G zQmr;UwpM3U2flURP*JeSmtr!iQKx^qWp%qR25D{4?w`JD>7FJfZMZGP6Hwaha7GIX ze$&Zh-GhB9%;EYIL|5)ee?)y*9)22=qfHQAtx2{*TJGlDJ+InfWg=sHt-Y(v(AglL zn!v%SCNX7B2>356I?Zk|;&#e+)kEN!A69(SRM-!<`K#Nwx;f|uQKptuc7|jCWfg}oXWov`goAnRhwVpx} zsfH|SEzl-K@$J#ho0y$>a&q$CFl$w12_hR^g_KG05Y_agu3RGnD9tlP({@RktzYSj zCj22)=v{Xwi{sAs3Ypr5oQN6%M5#;u<#DR0M~>B*Ph_Q=>is)To_Y%A3DN*>0aF!m zZ-r4MiGe3V4UDrPf$&=SEZw2yLqCk;)<=o%eaZ$BYk^3~Cw(-F5IquzBPeR=_4U-s z0$Lo|;?+pWP*jjXMGJF9X$_Zk5+;bZ_UoZn%VWx9mpCm$ew6kr?Go-#*U4{%p2mMA zdB=}I+L4Nb%X8CDS4UT+7nPH%v>VwA;GDBKO&JC!7J$$Q1yYBBeJ+Wuk6HP=snwW70R4}g|(ASIu{}; zzmQPhyIx!>Dotnk(E2UxpUtv634gR+NfjGBAbVB_I$%Gl8;5!8frgw1hT2I^Bi7bV z`XOJ| zJ&`h&f6-a<3uTlh>Pph~;dK`*+M$1NX36??^t?TOJpHN83?a=Jn$k2BiY@~4E|SNyguP}fUn_AE)4i&$1Wi*3`S8A|IIJ&&9wW_6G;Ixi z9zK-ULBas4v;Bcam~$VOqqRvdb!OFc=G1lO)NI0_(_A8+)?-Qyt~o_CRGXgCkP<+4 zj6KkV>B>G}%d7CP%XHi9Qc!aNn|awb2+O`e;(T_LVpsfjyE9~jeNStoc-sqkRuoN)sZ1n6fMJ8)*18T0U zNmX(U^)o;&`1qbJYKGX0Yau*MV^^Dev??TQZHHiEv~#sDqE)H5bUZ^6_!Xyyxs2RWF=bk8d4P05}{4sE82Va){l@UQ*7M2&rNSINr8Pg7|d79cc zz*iqOP7v(}QMCBv(goCnSTL~1RW`7(!l`yd*o|h$e48lfrOlK{%0#u*ZvE1l)C*t7 z^xZGq0%xlhZ)WTQa4~Uc9&|FDxMe$A0!k+Wlth2 z>vP;dVzr5H5U_ z_Y41xDzW(o>VMlk$V-8O{bvBg-$(fG-?Zff06+m?%*xEhWXfP{$jZdVYRb&Wz`@99 Z!eL^>YRtmH!NJ7B@b}geMrL;A{|`b4zyJUM literal 0 HcmV?d00001 diff --git a/artifacts/meta/kfort_d6473f1/bots/kfort/main.py b/artifacts/meta/kfort_d6473f1/bots/kfort/main.py new file mode 100644 index 0000000..8a5f992 --- /dev/null +++ b/artifacts/meta/kfort_d6473f1/bots/kfort/main.py @@ -0,0 +1,1515 @@ +"""Khaos rebuild — memoryless state-scoring bot (Pantheon architecture). + +Every builder, every turn: score seven candidate states in descending ceiling +order with early exit, run the single best. No mode flags, no multi-turn +commitments — targets are re-derived each turn from the bitmask map layer, +deconflicted through a Voronoi claim partition, with TTL caches for targets +that recently failed. + +States (ceiling): attack 9 · heal 8 · route 7.75 · secure 7.5 · harvest 4 · +disrupt 2 · explore 1. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +import mapanalysis +import mapinfo as mapinfo_mod +import path +import planes +import symmetry + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +DELTA_TO_DIR = { + (0, -1): Direction.NORTH, (0, 1): Direction.SOUTH, + (1, 0): Direction.EAST, (-1, 0): Direction.WEST, +} + +GLOBAL_AMMO_BUFFER = 20 +MAX_OPEN_BUILDERS = 5 +TTL_ROUNDS = 100 +CPU_SOFT_STOP = 6500 # stop scoring lower states past this many us + + +def manhattan(a, b): + return abs(a.x - b.x) + abs(a.y - b.y) + + +class Player: + def __init__(self): + self.mi = None + self.myNum = -1 + self.numSpawned = 0 + self.teamCore = None + self.teamCoreTiles = None + self.enemyCore = None + self.symTracker = None + self.fullmap = None + self.fullmapVer = -1 + self.routeField = None + self.routeVer = -1 + self._connected = set() # own conveyors whose chain reaches the core + self._homeDisc = None # cached home-guard disc for builder #1 + self.brokenBarriers = [] # own barriers we broke while walking + self._plantCache = None # score-plane best placement cache + self.enemyField = None # dist-to-enemy-core field (attack route) + self.enemyFieldVer = -1 + self.ttl = {} # (state, x, y) -> expiry round + self.stuck = 0 + self.lastPos = None + self.exploreTarget = None + # turret / launcher state + self.idleTurns = 0 + self.enemyBotHistory = {} + self.tapWait = 0 # sentinel one-tap coordination hold counter + + # ------------------------------------------------------------------ + def run(self, ct: Controller) -> None: + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.core(ct) + elif etype == EntityType.BUILDER_BOT: + self.builder(ct) + elif etype == EntityType.SENTINEL: + self.sentinel(ct) + elif etype == EntityType.GUNNER: + self.gunner(ct) + elif etype == EntityType.LAUNCHER: + self.launcher(ct) + + # ------------------------------------------------------------------ + # Core: fan-spawn opening, defensive spawn, ammo buffer + # ------------------------------------------------------------------ + def core(self, ct: Controller) -> None: + import math + pos = ct.get_position() + if self.mi is None: + w, h = ct.get_map_width(), ct.get_map_height() + self.mi = mapinfo_mod.MapInfo(w, h) + mapanalysis.reset_map(w, h) + rnd = ct.get_current_round() + ti = ct.get_global_resources() + # Pantheon spawn policy: baseline 200 until 12 allies, then 400 — + # spawn whenever titanium clears baseline + the builder's own cost. + # (Our old 350/400*scale gates stalled workforce growth exactly + # during establishment.) + baseline = 400 if ct.get_unit_count() >= 12 else 200 + wantSpawn = ( + ti >= baseline + ct.get_builder_bot_cost() + or (rnd >= 30 and ct.get_unit_count() <= 6 + and ti >= ct.get_builder_bot_cost() + 60)) + if wantSpawn: + candidates = [] + for dx in (-1, 0, 1, 2): + for dy in (-1, 0, 1, 2): + if 0 <= dx <= 1 and 0 <= dy <= 1: + continue + p = Position(pos.x + dx, pos.y + dy) + if ct.can_spawn(p): + candidates.append(p) + if candidates: + ccx, ccy = pos.x + 0.5, pos.y + 0.5 + pick = None + # defensive: intruder at the base with no ally on it + enemyPos, enemyDist, allies = None, 1e9, [] + for eid in ct.get_nearby_units(): + ep = ct.get_position(eid) + if ct.get_team(eid) == ct.get_team(): + if ct.get_entity_type(eid) == EntityType.BUILDER_BOT: + allies.append(ep) + continue + d = (ep.x - ccx) ** 2 + (ep.y - ccy) ** 2 + if d < enemyDist: + enemyDist, enemyPos = d, ep + if enemyPos is not None and enemyDist <= 18 and not any( + a.distance_squared(enemyPos) <= 8 for a in allies): + pick = min(candidates, key=lambda p: manhattan(p, enemyPos)) + elif self.numSpawned < 4: + w, h = ct.get_map_width(), ct.get_map_height() + primary = math.atan2(h / 2 - ccy, w / 2 - ccx) + target = primary + (-1.178, -0.393, 0.393, 1.178)[self.numSpawned] + tx, ty = math.cos(target), math.sin(target) + + def align(p): + vx, vy = p.x + 0.5 - ccx, p.y + 0.5 - ccy + norm = math.hypot(vx, vy) or 1.0 + return -(vx * tx + vy * ty) / norm + pick = min(candidates, key=align) + else: + pick = candidates[0] + ct.spawn_builder(pick) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) + # distress: hurt core or enemy building in core vision -> broadcast on + # slot 7 so fanned-out builders converge to heal/counter-battery. + distress = ct.get_hp() < ct.get_max_hp() + if not distress: + for bid in ct.get_nearby_buildings(): + if ct.get_team(bid) != ct.get_team(): + distress = True + break + if distress: + ct.write_store(7, rnd) + # ammo buffer (raised while distressed so counter-turrets can fire) + buffer = 40 if distress else GLOBAL_AMMO_BUFFER + cur = ct.get_global_ammo() + if cur < buffer: + spare = ct.get_global_resources() - ct.get_builder_bot_cost() + amount = min(buffer - cur, spare) + if amount > 0 and ct.can_convert_ammo(amount): + ct.convert_ammo(amount) + + # ------------------------------------------------------------------ + # Builder: the memoryless spine + # ------------------------------------------------------------------ + def builder(self, ct: Controller) -> None: + mi = self._setup(ct) + mi.update_vision(ct) + mi.absorb(ct) + self._updateCores(ct) + self._updateSymmetry(ct) + + rnd = ct.get_current_round() + pos = ct.get_position() + if self.lastPos == pos: + self.stuck += 1 + else: + self.stuck = 0 + self.lastPos = pos + + # ttl sweep + if rnd % 16 == 0: + self.ttl = {k: v for k, v in self.ttl.items() if v > rnd} + + # death memory (Ijti fix): ban tiles where our turrets died + if mi.turret_losses: + for (lx, ly) in mi.turret_losses: + self.ttl[('plant', lx, ly)] = rnd + 80 + mi.turret_losses.clear() + self._plantCache = None + + # claims context: my bit vs other friendly bots + my_bit = mi.bit(pos.x, pos.y) + others = mi.own_bots & ~my_bit + + states = ( + (9.0, self._score_attack, self._run_attack), + (8.0, self._score_heal, self._run_heal), + (7.75, self._score_route, self._run_route), + (7.5, self._score_secure, self._run_secure), + (4.0, self._score_harvest, self._run_harvest), + (2.0, self._score_disrupt, self._run_disrupt), + (1.0, self._score_explore, self._run_explore), + ) + best, bestScore, bestData = None, 0.0, None + for ceil, scorefn, runfn in states: + if bestScore >= ceil: + break + if ct.get_cpu_time_elapsed() > CPU_SOFT_STOP and best is not None: + break + s, data = scorefn(ct, mi, my_bit, others) + if s > bestScore: + best, bestScore, bestData = runfn, s, data + if best is not None: + best(ct, mi, bestData) + + # passive extras with leftover action/cpu + self._rebuildBroken(ct, mi) + self._ringGuns(ct, mi) + self._tryChokeBlock(ct, mi) + self._denyHomeSiege(ct, mi) + self._healAdjacent(ct) + mi.share(ct, self.myNum, self.teamCore) + self._advanceAnalysis(ct) + + # ---- setup helpers ---- + def _setup(self, ct): + if self.mi is None: + w, h = ct.get_map_width(), ct.get_map_height() + self.mi = mapinfo_mod.MapInfo(w, h) + mapanalysis.configure_map(w, h) + self.mi.on_block_change = ( + lambda x, y, blocking: mapanalysis.note_structural_tile( + x, y, blocking, w, h)) + if self.myNum == -1: + self.myNum = ct.read_store(0) + 1 + return self.mi + + def _updateCores(self, ct): + if self.teamCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) == ct.get_team()): + self.teamCore = ct.get_position(bid) + if self.enemyCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) != ct.get_team()): + self.enemyCore = ct.get_position(bid) + + def _updateSymmetry(self, ct): + mi = self.mi + if self.teamCore is None: + return + if self.symTracker is None: + self.symTracker = symmetry.SymmetryTracker(mi.w, mi.h) + if self.fullmapVer != mi.struct_version or self.fullmap is None: + fm = [[-1] * mi.h for _ in range(mi.w)] + for x in range(mi.w): + col = fm[x] + for y in range(mi.h): + b = 1 << (x + y * mi.w) + if not (mi.seen & b): + continue + if mi.walls & b: + col[y] = 2 + elif mi.blocked & b: + col[y] = 3 + elif mi.ore & b: + col[y] = 1 + else: + col[y] = 0 + self.fullmap = fm + self.fullmapVer = mi.struct_version + self.symTracker.update(self.fullmap, mi.struct_version, self.teamCore) + + def _enemyCoreGuess(self): + if self.enemyCore is not None: + return self.enemyCore + if self.symTracker is not None and self.teamCore is not None: + cx, cy = self.symTracker.enemy_core(self.teamCore) + if 0 <= cx < self.mi.w - 1 and 0 <= cy < self.mi.h - 1: + return Position(cx, cy) + return None + + def _homeMask(self): + """Tiles within squared distance 100 of the core (Pantheon: the very + first builder restricts all its targets to this disc = home guard).""" + mi = self.mi + if self._homeDisc is not None or self.teamCore is None: + return self._homeDisc or 0 + cx, cy = self.teamCore.x, self.teamCore.y + m = 0 + for x in range(max(0, cx - 10), min(mi.w, cx + 11)): + for y in range(max(0, cy - 10), min(mi.h, cy + 11)): + if (x - cx) ** 2 + (y - cy) ** 2 <= 100: + m |= mi.bit(x, y) + self._homeDisc = m + return m + + def _coreMask(self): + mi = self.mi + if self.teamCore is None: + return 0 + m = 0 + for dx in (0, 1): + for dy in (0, 1): + x, y = self.teamCore.x + dx, self.teamCore.y + dy + if 0 <= x < mi.w and 0 <= y < mi.h: + m |= mi.bit(x, y) + return m + + # ---- movement ---- + def _moveToward(self, ct, targets_mask, avoid=0): + if ct.get_move_cooldown() != 0: + return + mi = self.mi + pos = ct.get_position() + step = path.next_step(mi, (pos.x, pos.y), targets_mask, avoid) + if step is not None: + d = DELTA_TO_DIR[step] + dest = pos.add(d) + # break-walk own barriers (Pantheon): destroy is free, remember + # the tile so we rebuild it once we've stepped off. + if (self._inBounds(dest) + and mi.own_barriers & mi.bit(dest.x, dest.y) + and not ct.can_move(d) + and ct.is_in_vision(dest) and ct.can_destroy(dest)): + ct.destroy(dest) + mi.own_barriers &= ~mi.bit(dest.x, dest.y) + mi.note_tile(dest.x, dest.y, mapinfo_mod.T_EMPTY) + if len(self.brokenBarriers) < 4: + self.brokenBarriers.append(dest) + if ct.can_move(d): + ct.move(d) + self.stuck = 0 + return + self.stuck += 1 + if self.stuck > 2 + (ct.get_id() % 8): + dirs = [d for d in CARDINALS if ct.can_move(d)] + if dirs: + ct.move(random.choice(dirs)) + + # ------------------------------------------------------------------ + # STATE: attack (9) — plant turrets on enemy buildings / push their core + # ------------------------------------------------------------------ + def _score_attack(self, ct, mi, my_bit, others): + rnd = ct.get_current_round() + ti = ct.get_global_resources() + if ti < ct.get_gunner_cost() + 40: + return 0.0, None + # DEFENSIVE counter-battery: enemy building near OUR core — every + # builder may respond (no role gate), claims still deconflict. + if self.teamCore is not None: + home = 0 + for (x, y) in mi.enemy_buildings: + if self.ttl.get(('atk', x, y), 0) > rnd: + continue + if abs(x - self.teamCore.x) + abs(y - self.teamCore.y) <= 10: + home |= mi.bit(x, y) + if home: + mine = path.claims(mi, my_bit, others, home) + if mine: + return 9.0, ('bld', mine) + if rnd < 30 or self.myNum % 3 != 0 or self.myNum == 1: + return 0.0, None # builder #1 is the home guard, never sieges + guess = self._enemyCoreGuess() + # bit-sliced plane placement (Pantheon): score EVERY legal gunner + # placement board-wide, take the best above threshold. + plant = self._bestPlant(ct, mi) + if plant is not None: + score, px, py, fk = plant + # geographic money gate on the PLACEMENT tile + if self.teamCore is not None and guess is not None: + dHome = abs(px - self.teamCore.x) + abs(py - self.teamCore.y) + dEnemy = abs(px - guess.x) + abs(py - guess.y) + deep = dHome > dEnemy + else: + deep = False + if not deep or ti >= 150: + mine = path.claims(mi, my_bit, others, mi.bit(px, py)) + if mine: + return 9.0, ('plant', (px, py, fk)) + # fallback: walk at remembered enemy buildings (old greedy pathway) + cands = 0 + for (x, y) in mi.enemy_buildings: + key = ('atk', x, y) + if self.ttl.get(key, 0) > rnd: + continue + if self.teamCore is not None and guess is not None: + dHome = abs(x - self.teamCore.x) + abs(y - self.teamCore.y) + dEnemy = abs(x - guess.x) + abs(y - guess.y) + onOurHalf = dHome <= dEnemy + else: + onOurHalf = True + if onOurHalf or ti >= 150: + cands |= mi.bit(x, y) + if cands: + mine = path.claims(mi, my_bit, others, cands) + if mine: + return 8.9, ('bld', mine) + return 0.0, None + if guess is not None: + return 6.0, ('core', guess) + return 0.0, None + + PLANT_VALUES = None # built lazily: EntityType -> placement value + + def _bestPlant(self, ct, mi): + """Best gunner placement from the score planes, cached per + (struct, enemy-set). Threshold 20 = better than a lone barrier.""" + if Player.PLANT_VALUES is None: + Player.PLANT_VALUES = { + EntityType.CORE: 60, EntityType.GUNNER: 40, + EntityType.SENTINEL: 40, EntityType.LAUNCHER: 20, + EntityType.CONVEYOR: 15, EntityType.SPLITTER: 18, + EntityType.HARVESTER: 8, EntityType.BARRIER: 5, + } + key = (mi.struct_version, + tuple(sorted((k, str(v)) + for k, v in mi.enemy_buildings.items()))) + if self._plantCache is not None and self._plantCache[0] == key: + return self._plantCache[1] + by_value = {} + for (x, y), etype in mi.enemy_buildings.items(): + v = Player.PLANT_VALUES.get(etype, 5) + by_value.setdefault(v, [0])[0] = by_value.get(v, [0])[0] \ + | mi.bit(x, y) + class_masks = [(v, m[0]) for v, m in by_value.items()] + valid = (mi.seen & ~mi.walls & ~mi.blocked & ~mi.ore + & ~mi.own_conveyors & ~mi.own_barriers + & ~mi.expand(mi.expand(mi.enemy_bots))) + # death memory (Ijti fix): freshly-fatal tiles are banned + rnd = ct.get_current_round() + for (kind, kx, ky), exp in self.ttl.items(): + if kind == 'plant' and exp > rnd: + valid &= ~mi.bit(kx, ky) + best = None + if class_masks and valid: + # threat penalty (Ijti fix): unthreatened placements first; + # threatened only at 2x value (trade, not feeding tube) + threat = mi.threat() + best = planes.best_placement(mi, class_masks, valid & ~threat) + if best is None or best[0] < 20: + hot = planes.best_placement(mi, class_masks, valid & threat) + if hot is not None and hot[0] >= 40 \ + and (best is None or hot[0] > best[0]): + best = hot + if best is not None and best[0] < 20: + best = None + self._plantCache = (key, best) + return best + + def _enemyFieldCached(self, mi, guess): + key = (mi.struct_version, guess.x, guess.y) + if self.enemyField is not None and self.enemyFieldVer == key: + return self.enemyField + m = 0 + for dx in (0, 1): + for dy in (0, 1): + x, y = guess.x + dx, guess.y + dy + if 0 <= x < mi.w and 0 <= y < mi.h: + m |= mi.bit(x, y) + self.enemyField = path.dist_field(mi, mi.expand(m)) + self.enemyFieldVer = key + return self.enemyField + + def _run_attack(self, ct, mi, data): + kind, payload = data + pos = ct.get_position() + rnd = ct.get_current_round() + if kind == 'core': + # ATTACK ROUTE (Pantheon override): while marching on the enemy + # core, lay a conveyor trail once we're geographically ahead + # (dist-to-them <= 1.5x dist-home). Walkable for us, area denial + # for them; planes convert the head into a gunner on contact. + if (ct.get_action_cooldown() == 0 + and ct.get_global_resources() + >= ct.get_conveyor_cost() + 120): + ef = self._enemyFieldCached(mi, payload) + hf = self._routeFieldCached(ct) + idx = pos.x + pos.y * mi.w + if (ef is not None and hf is not None + and ef[idx] < 4096 and hf[idx] < 4096 + and ef[idx] <= 1.5 * hf[idx]): + best = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = pos.x + dx, pos.y + dy + if not (0 <= nx < mi.w and 0 <= ny < mi.h): + continue + b = mi.bit(nx, ny) + if (mi.walls | mi.blocked | mi.own_conveyors + | mi.own_barriers | mi.ore) & b: + continue + d = ef[nx + ny * mi.w] + if best is None or d < best[0]: + best = (d, nx, ny) + if best is not None and best[0] < ef[idx]: + _, lx, ly = best + link = Position(lx, ly) + # face further downhill toward the enemy core + fb = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + fx, fy = lx + dx, ly + dy + if not (0 <= fx < mi.w and 0 <= fy < mi.h): + continue + fd = ef[fx + fy * mi.w] + if fb is None or fd < fb[0]: + fb = (fd, dx, dy) + if fb is not None: + facing = DELTA_TO_DIR[(fb[1], fb[2])] + if (ct.is_in_vision(link) + and ct.can_build_conveyor(link, facing)): + ct.build_conveyor(link, facing) + mi.own_conveyors |= mi.bit(lx, ly) + mi.own_conv_facing[(lx, ly)] = (fb[1], fb[2]) + m = mi.bit(payload.x, payload.y) + self._moveToward(ct, mi.expand(mi.expand(m))) + return + if kind == 'plant': + px, py, fk = payload + tile = Position(px, py) + facing = {'E': Direction.EAST, 'W': Direction.WEST, + 'N': Direction.NORTH, 'S': Direction.SOUTH}[fk] + tb = mi.bit(px, py) + if pos == tile: + # standing on the placement: step aside + self._moveToward(ct, mi.expand(tb) & mi.passable() & ~tb) + return + if pos.distance_squared(tile) <= 2 \ + and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tile) and ct.can_build_gunner(tile, facing): + ct.build_gunner(tile, facing) + self._plantCache = None + return + self.ttl[('atk', px, py)] = rnd + 20 + self._plantCache = None + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + return + targets = payload + tb = targets & -targets + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + dist = manhattan(pos, tpos) + if dist <= 3 and ct.get_action_cooldown() == 0: + # place a turret on an adjacent tile with line to the target + facing = self._cardinalTo(pos, tpos) + placed = False + for d in CARDINALS: + p = pos.add(d) + if not self._inBounds(p) or not ct.is_in_vision(p): + continue + f = self._cardinalTo(p, tpos) + if f == Direction.CENTRE: + continue + if ct.can_build_gunner(p, f): + ct.build_gunner(p, f) + placed = True + break + if (ct.get_global_resources() >= ct.get_sentinel_cost() + 40 + and ct.can_build_sentinel(p, f)): + ct.build_sentinel(p, f) + placed = True + break + if placed: + self.ttl[('atk', tx, ty)] = rnd + 40 + return + if facing == Direction.CENTRE: + self.ttl[('atk', tx, ty)] = rnd + TTL_ROUNDS + return + if dist <= 1: + # cannot place and already adjacent: back off this target a while + self.ttl[('atk', tx, ty)] = rnd + 30 + self._moveToward(ct, mi.expand(targets) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: heal (8) — repair damaged own buildings, deter intruders + # ------------------------------------------------------------------ + def _score_heal(self, ct, mi, my_bit, others): + damaged = 0 + # core distress broadcast: converge on the core even when it's out of + # our own vision — 2 medics out-heal a sentinel's grind (8 vs 6/round). + distress = ct.read_store(7) + if (distress > 0 and ct.get_current_round() - distress < 12 + and self.teamCore is not None): + damaged |= self._coreMask() + for bid in ct.get_nearby_buildings(): + if ct.get_team(bid) != ct.get_team(): + continue + if ct.get_hp(bid) + 2 < ct.get_max_hp(bid): + p = ct.get_position(bid) + damaged |= mi.bit(p.x, p.y) + if damaged: + mine = path.claims(mi, my_bit, others, damaged) + if mine: + return 8.0, ('heal', mine) + # Pantheon: own sentinel trading with an enemy sentinel counts as very + # damaged — medics pre-park. Scores 7, BELOW route's 7.75 (the paper's + # exact number; 8.0 parked the workforce and starved the economy). + trading = 0 + for (x, y), tk in mi.own_turrets.items(): + if tk != 'S': + continue + for (ex, ey), ekind in mi.enemy_turrets.items(): + if ekind == 'S' and (x - ex) ** 2 + (y - ey) ** 2 <= 32: + trading |= mi.bit(x, y) + break + if trading: + mine = path.claims(mi, my_bit, others, trading) + if mine: + return 7.0, ('heal', mine) + # Pantheon chase zone: 8 pathing steps around our conveyor network + # (was 2 tiles) — intruders get hunted before they reach the chain. + zone = mi.own_conveyors + passable = mi.passable() + for _ in range(8): + zone = mi.expand(zone) & (passable | zone) + intruders = mi.enemy_bots & zone + if intruders: + mine = path.claims(mi, my_bit, others, intruders) + if mine: + return 7.9, ('chase', mine) + return 0.0, None + + def _run_heal(self, ct, mi, data): + kind, mask = data + pos = ct.get_position() + tb = mask & -mask + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + if kind == 'heal': + if pos.distance_squared(tpos) <= 2: + if ct.can_heal(tpos): + ct.heal(tpos) + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + return + # chase: stand next to the intruder; the launcher/turret net does the + # rest, and our body denies conveyor-chip tiles + if pos.distance_squared(tpos) <= 2: + if ct.get_action_cooldown() == 0: + for d in CARDINALS: + p = pos.add(d) + if (self._inBounds(p) and ct.is_in_vision(p) + and manhattan(p, tpos) <= 1 + and ct.can_build_launcher(p) + and ct.get_global_resources() + >= ct.get_launcher_cost() + 60): + ct.build_launcher(p) + return + return + # Pantheon counter-launcher fallback: chase can't PATH to the + # intruder (their launcher zone / barriers) -> zone them back + # instead of giving up. + step = path.next_step(mi, (pos.x, pos.y), + mi.expand(tb) & mi.passable()) + if step is None: + if (manhattan(pos, tpos) <= 3 and ct.get_action_cooldown() == 0 + and ct.get_global_resources() + >= ct.get_launcher_cost() + 60): + for d in CARDINALS: + p = pos.add(d) + if (self._inBounds(p) and ct.is_in_vision(p) + and manhattan(p, tpos) <= 2 + and ct.can_build_launcher(p)): + ct.build_launcher(p) + return + self._moveToward(ct, mi.expand(mi.expand(tb)) & mi.passable()) + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: route (7.75) — stateless chain building down the core gradient + # ------------------------------------------------------------------ + def _connectedConveyors(self, mi): + """Own conveyors whose chain (by facing) reaches the core — legal + merge targets for new routes (Pantheon: routes may end on existing + conveyors). Fixpoint over remembered facings.""" + coreMask = self._coreMask() + core_xy = set() + m = coreMask + while m: + lsb = m & -m + core_xy.add(mi.xy(lsb)) + m ^= lsb + connected = set() + changed = True + while changed: + changed = False + for (x, y), (dx, dy) in mi.own_conv_facing.items(): + if (x, y) in connected: + continue + out = (x + dx, y + dy) + if out in core_xy or out in connected: + connected.add((x, y)) + changed = True + return connected + + def _routeFieldCached(self, ct): + mi = self.mi + key = (mi.struct_version, len(mi.own_conv_facing), + tuple(sorted(mi.enemy_turrets))) + if self.routeField is not None and self.routeVer == key: + return self.routeField + coreMask = self._coreMask() + if not coreMask: + return None + # Pantheon merge targets: the core PLUS every conveyor already + # connected to it — new chains join existing trunks instead of each + # harvester paying for a private line the whole way home. LOADED + # trunk tiles are excluded (P6): a full conveyor can't accept a new + # stack, so merging there jams the junction (their +4 penalty, made + # binary by dev23's hold-1-stack rule). + self._connected = self._connectedConveyors(mi) + targets = coreMask + for (x, y) in self._connected: + targets |= mi.bit(x, y) + targets &= ~mi.own_loaded | coreMask + # Pantheon universal AVOID mask (p7): tiles covered by enemy turrets + # are HARD-excluded from conveyor route planning, not soft-costed — + # a link placed under a known gun is titanium fed to the enemy + # (Landers vault r139: we rebuilt one threatened link 7 times). + # Existing own conveyors stay routable so trunks aren't orphaned. + avoid = mi.threat() & ~mi.own_conveyors & ~coreMask + self.routeField = path.dist_field(mi, targets, avoid_mask=avoid) + self.routeVer = key + return self.routeField + + @staticmethod + def _chainRoot(mi, hx, hy): + """Adjacent own conveyor whose output is NOT the harvester — a real + chain root. Inward-facing guards don't count (Khaos guard mask).""" + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + cx, cy = hx + dx, hy + dy + f = mi.own_conv_facing.get((cx, cy)) + if f is None: + continue + if (cx + f[0], cy + f[1]) != (hx, hy): + return (cx, cy) + return None + + def _downhillLink(self, mi, field, x, y): + """Best empty neighbour to lay the next conveyor on, plus the facing + that conveyor should have (toward ITS best downhill neighbour).""" + best = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if not (0 <= nx < mi.w and 0 <= ny < mi.h): + continue + b = mi.bit(nx, ny) + if (mi.walls | mi.blocked | mi.own_conveyors) & b: + continue + d = field[nx + ny * mi.w] + if best is None or d < best[0]: + best = (d, nx, ny) + if best is None or best[0] >= 4096: + return None, None + _, lx, ly = best + fbest = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + fx, fy = lx + dx, ly + dy + if not (0 <= fx < mi.w and 0 <= fy < mi.h): + continue + fd = field[fx + fy * mi.w] + if fbest is None or fd < fbest[0]: + fbest = (fd, dx, dy) + if fbest is None: + return None, None + return Position(lx, ly), DELTA_TO_DIR[(fbest[1], fbest[2])] + + def _orphanHarvesters(self, ct, mi): + rnd = ct.get_current_round() + orphans = 0 + for (x, y) in mi.own_harvesters: + key = ('route', x, y) + if self.ttl.get(key, 0) > rnd: + continue + if self._chainRoot(mi, x, y) is None: + orphans |= mi.bit(x, y) + return orphans + + def _deadEnds(self, ct, mi): + """Own conveyors whose output tile has no continuation (Khaos + dead-end route targets). Guards (facing into a harvester) excluded.""" + rnd = ct.get_current_round() + coreMask = self._coreMask() + ends = 0 + for (x, y), (dx, dy) in mi.own_conv_facing.items(): + if self.ttl.get(('route', x, y), 0) > rnd: + continue + ox, oy = x + dx, y + dy + if (ox, oy) in mi.own_harvesters: + continue # inward guard, carries nothing + if not (0 <= ox < mi.w and 0 <= oy < mi.h): + ends |= mi.bit(x, y) + continue + ob = mi.bit(ox, oy) + if ob & coreMask: + continue # delivers into the core: chain complete + if mi.own_conveyors & ob: + continue # continues into another conveyor/splitter + ends |= mi.bit(x, y) + return ends + + def _score_route(self, ct, mi, my_bit, others): + if self.teamCore is None: + return 0.0, None + cands = self._orphanHarvesters(ct, mi) | self._deadEnds(ct, mi) + if not cands: + return 0.0, None + mine = path.claims(mi, my_bit, others, cands) + if not mine: + return 0.0, None + near_enemy = mi.expand(mi.expand(mi.enemy_bots)) + important = mine & near_enemy + return (7.75 if important else 5.0), mine + + def _run_route(self, ct, mi, mask): + field = self._routeFieldCached(ct) + rnd = ct.get_current_round() + hb = mask & -mask + hx, hy = mi.xy(hb) + if field is None: + return + coreMask = self._coreMask() + link = facing = None + if (hx, hy) in mi.own_conv_facing: + root = (hx, hy) # dead-end conveyor target: walk from it + else: + root = self._chainRoot(mi, hx, hy) + # P6 cost gate (Pantheon cost(d)): before STARTING a fresh chain, + # check we can afford ~the whole line. 0.65 discount — income + # keeps arriving while we build. + d = field[hx + hy * mi.w] + if d < 4096: + est = 3 * d * ct.get_scale_percent() / 100 * 0.65 + if ct.get_global_resources() < est: + self.ttl[('route', hx, hy)] = rnd + 30 + return + if root is None: + link, facing = self._downhillLink(mi, field, hx, hy) + else: + # follow the actual chain by facings until it breaks / reaches core + cur = root + visited = set() + for _ in range(mi.w + mi.h): + if cur in visited: + self.ttl[('route', hx, hy)] = rnd + 30 + return + visited.add(cur) + f = mi.own_conv_facing.get(cur) + if f is None: + self.ttl[('route', hx, hy)] = rnd + 15 + return + ox, oy = cur[0] + f[0], cur[1] + f[1] + if not (0 <= ox < mi.w and 0 <= oy < mi.h): + link, facing = self._downhillLink(mi, field, + cur[0], cur[1]) + break + ob = mi.bit(ox, oy) + if ob & coreMask or (ox, oy) in self._connected: + self.ttl[('route', hx, hy)] = rnd + 30 # chain complete + return + if (ox, oy) in mi.own_conv_facing: + cur = (ox, oy) + continue + if mi.own_conveyors & ob: + # splitter / facing not yet observed: assume connected + self.ttl[('route', hx, hy)] = rnd + 15 + return + if (mi.walls | mi.blocked) & ob: + link, facing = self._downhillLink(mi, field, + cur[0], cur[1]) + break + # empty output tile: that's the missing link + link = Position(ox, oy) + fbest = None + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + fx, fy = ox + dx, oy + dy + if not (0 <= fx < mi.w and 0 <= fy < mi.h): + continue + fd = field[fx + fy * mi.w] + if fbest is None or fd < fbest[0]: + fbest = (fd, dx, dy) + if fbest is None: + return + facing = DELTA_TO_DIR[(fbest[1], fbest[2])] + break + if link is None: + # Pantheon: an unroutable dead-end near enemies gets CAPPED with + # a barrier on its output so they can't exploit/extend it + f = mi.own_conv_facing.get((hx, hy)) + if f is not None and mi.enemy_bots: + near = mi.expand(mi.expand(mi.expand(mi.expand( + mi.bit(hx, hy))))) + if near & mi.enemy_bots and ct.get_action_cooldown() == 0: + ox, oy = hx + f[0], hy + f[1] + if 0 <= ox < mi.w and 0 <= oy < mi.h: + cap = Position(ox, oy) + pos0 = ct.get_position() + if (pos0.distance_squared(cap) <= 2 + and ct.is_in_vision(cap) + and ct.can_build_barrier(cap)): + ct.build_barrier(cap) + mi.own_barriers |= mi.bit(ox, oy) + mi.note_tile(ox, oy, mapinfo_mod.T_BLOCK) + self.ttl[('route', hx, hy)] = rnd + 20 + return + pos = ct.get_position() + lb = mi.bit(link.x, link.y) + if pos == link: + # standing on the link tile: step aside so we can build it + self._moveToward(ct, mi.expand(lb) & mi.passable() & ~lb) + return + if pos.distance_squared(link) <= 2 and ct.get_action_cooldown() == 0: + if ct.is_in_vision(link) and ct.can_build_conveyor(link, facing): + ct.build_conveyor(link, facing) + mi.own_conveyors |= lb + mi.own_conv_facing[(link.x, link.y)] = ( + mapinfo_mod.DIR_DELTA[facing]) + return + self.ttl[('route', hx, hy)] = rnd + 10 + return + self._moveToward(ct, mi.expand(lb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: secure (7.5) — guard-conveyor exposed harvesters near enemies + # ------------------------------------------------------------------ + def _score_secure(self, ct, mi, my_bit, others): + if not mi.enemy_buildings and not mi.enemy_bots: + return 0.0, None + danger = 0 + for (x, y) in mi.enemy_buildings: + danger |= mi.bit(x, y) + danger = mi.expand(mi.expand(mi.expand(danger))) | \ + mi.expand(mi.expand(mi.enemy_bots)) + exposed = 0 + for (x, y) in mi.own_harvesters: + b = mi.bit(x, y) + if not (danger & mi.expand(b)): + continue + open_sides = mi.expand(b) & ~b & mi.passable() & ~mi.own_conveyors + if open_sides: + exposed |= open_sides + if not exposed: + return 0.0, None + mine = path.claims(mi, my_bit, others, exposed) + if not mine: + return 0.0, None + return 7.5, mine + + def _run_secure(self, ct, mi, mask): + pos = ct.get_position() + tb = mask & -mask + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + if pos.distance_squared(tpos) <= 2 and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tpos): + # find the harvester this side belongs to + harv = None + for d in CARDINALS: + hp = tpos.add(d) + if (self._inBounds(hp) + and (hp.x, hp.y) in mi.own_harvesters): + harv = (hp, d) + break + if harv is None: + return + hp, inward = harv + # Khaos: if this side is the harvester's downhill side, face + # OUTWARD so the guard doubles as the chain root; otherwise + # face INTO the harvester so it never carries resources. + facing = inward + field = self._routeFieldCached(ct) + if field is not None: + dlink, dfacing = self._downhillLink(mi, field, hp.x, hp.y) + if dlink is not None and dlink == tpos: + facing = dfacing + if ct.can_build_conveyor(tpos, facing): + ct.build_conveyor(tpos, facing) + mi.own_conveyors |= mi.bit(tpos.x, tpos.y) + mi.own_conv_facing[(tpos.x, tpos.y)] = ( + mapinfo_mod.DIR_DELTA[facing]) + return + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: harvest (4) + # ------------------------------------------------------------------ + def _score_harvest(self, ct, mi, my_bit, others): + rnd = ct.get_current_round() + # exclude only ore in the CLOSE disc of an enemy turret. The full + # facing-agnostic band mask vetoed most of the ore field on turret-y + # maps (quarry: 5-10k mined vs botv2's 18k) — band-edge mining is + # viable with heal upkeep (v29 lesson). + hot = 0 + for (x, y) in mi.enemy_turrets: + hot |= mi.bit(x, y) + hot = mi.expand(mi.expand(hot)) + cands = mi.ore & mi.seen & ~mi.blocked & ~mi.walls & ~hot + pruned = 0 + for lsb in mi.iter_bits(cands): + x, y = mi.xy(lsb) + if (x, y) in mi.own_harvesters: + continue + if self.ttl.get(('harv', x, y), 0) > rnd: + continue + pruned |= lsb + if self.myNum == 1: + pruned &= self._homeMask() # home guard mines home ore only + if not pruned: + return 0.0, None + mine = path.claims(mi, my_bit, others, pruned) + if not mine: + return 0.0, None + if ct.get_global_resources() < ct.get_harvester_cost(): + return 0.5, mine + return 4.0, mine + + def _run_harvest(self, ct, mi, mask): + pos = ct.get_position() + target = path.closest(mi, mi.bit(pos.x, pos.y), mask) + if not target: + target = mask & -mask + tx, ty = mi.xy(target) + tpos = Position(tx, ty) + rnd = ct.get_current_round() + if pos == tpos: + # standing on the ore: sidestep so we can build it + self._moveToward(ct, mi.expand(target) & mi.passable() & ~target) + return + if pos.distance_squared(tpos) <= 2 and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tpos) and ct.can_build_harvester(tpos): + ct.build_harvester(tpos) + mi.own_harvesters[(tx, ty)] = True + return + self.ttl[('harv', tx, ty)] = rnd + 25 + return + self._moveToward(ct, mi.expand(target) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: disrupt (2) — barrier enemy-half ore when rich + # ------------------------------------------------------------------ + def _score_disrupt(self, ct, mi, my_bit, others): + if self.myNum == 1: + return 0.0, None # home guard stays home + if ct.get_global_resources() < 5 * ct.get_harvester_cost(): + return 0.0, None + guess = self._enemyCoreGuess() + if guess is None or self.teamCore is None: + return 0.0, None + rnd = ct.get_current_round() + cands = 0 + for lsb in mi.iter_bits(mi.ore & mi.seen & ~mi.blocked + & ~mi.threat()): + x, y = mi.xy(lsb) + p = Position(x, y) + if manhattan(p, guess) < manhattan(p, self.teamCore) - 2: + if self.ttl.get(('dis', x, y), 0) <= rnd: + cands |= lsb + if not cands: + return 0.0, None + mine = path.claims(mi, my_bit, others, cands) + if not mine: + return 0.0, None + return 2.0, mine + + def _run_disrupt(self, ct, mi, mask): + pos = ct.get_position() + tb = mask & -mask + tx, ty = mi.xy(tb) + tpos = Position(tx, ty) + rnd = ct.get_current_round() + if pos.distance_squared(tpos) <= 2 and pos != tpos \ + and ct.get_action_cooldown() == 0: + if ct.is_in_vision(tpos) and ct.can_build_barrier(tpos): + ct.build_barrier(tpos) + self.ttl[('dis', tx, ty)] = rnd + 1000 + return + self.ttl[('dis', tx, ty)] = rnd + 50 + return + if pos == tpos: + self._moveToward(ct, mi.expand(tb) & mi.passable() & ~tb) + return + self._moveToward(ct, mi.expand(tb) & mi.passable()) + + # ------------------------------------------------------------------ + # STATE: explore (1) + # ------------------------------------------------------------------ + def _score_explore(self, ct, mi, my_bit, others): + return 1.0, None + + def _run_explore(self, ct, mi, _): + pos = ct.get_position() + if (self.exploreTarget is not None + and manhattan(pos, self.exploreTarget) <= 2): + self.exploreTarget = None + if self.exploreTarget is None or self.stuck > 4: + # home guard: patrol the undenied siege-ray tiles first so the + # denial wall goes up before anyone arrives to use those tiles + if self.myNum == 1 and self.teamCore is not None: + todo = [] + for (x, y) in self._siegeRayTiles(self.teamCore): + p = Position(x, y) + b = mi.bit(x, y) + if (mi.walls | mi.blocked | mi.own_barriers) & b: + continue + if ct.is_in_vision(p) and ( + ct.get_tile_env(p) == Environment.ORE_TITANIUM + or ct.get_tile_building_id(p) is not None): + continue + todo.append((x, y)) + if todo: + x, y = min(todo, key=lambda t: (pos.x - t[0]) ** 2 + + (pos.y - t[1]) ** 2) + self.exploreTarget = Position(x, y) + self._moveToward(ct, mi.expand(mi.bit(x, y)) + & mi.passable()) + return + frontier = mi.frontier() & mi.passable() + if self.myNum == 1: + frontier &= self._homeMask() # guard patrols home only + picked = None + if frontier: + bits = list(mi.iter_bits(frontier)) + picked = random.choice(bits[:64]) + if picked: + x, y = mi.xy(picked) + self.exploreTarget = Position(x, y) + elif self.myNum == 1 and self.teamCore is not None: + self.exploreTarget = Position( + max(0, min(mi.w - 1, + self.teamCore.x + random.randint(-4, 4))), + max(0, min(mi.h - 1, + self.teamCore.y + random.randint(-4, 4)))) + else: + self.exploreTarget = Position(random.randrange(mi.w), + random.randrange(mi.h)) + t = self.exploreTarget + self._moveToward(ct, mi.bit(t.x, t.y)) + + # ------------------------------------------------------------------ + # passive extras + # ------------------------------------------------------------------ + def _rebuildBroken(self, ct, mi): + """Replace own barriers we destroyed to walk through (Pantheon).""" + if not self.brokenBarriers or ct.get_action_cooldown() != 0: + return + pos = ct.get_position() + for p in list(self.brokenBarriers): + if pos == p: + continue # still standing on it; rebuild after stepping off + if pos.distance_squared(p) > 2: + self.brokenBarriers.remove(p) # walked away: let it go + continue + if ct.is_in_vision(p) and ct.can_build_barrier(p): + ct.build_barrier(p) + mi.own_barriers |= mi.bit(p.x, p.y) + mi.note_tile(p.x, p.y, mapinfo_mod.T_BLOCK) + self.brokenBarriers.remove(p) + return + + def _siegeRayTiles(self, core): + """The tiles a gunner could shoot the given 2x2 core from: cardinal + rays off each footprint row/col at distance 2-3 (range^2 13 caps the + ray at 3). Distance-1 ring is left free — ours spawns there, theirs + is where our own ring guns go.""" + mi = self.mi + tiles = [] + for o in (0, 1): + for d in (2, 3): + tiles.append((core.x - d, core.y + o)) # west ray + tiles.append((core.x + 1 + d, core.y + o)) # east ray + tiles.append((core.x + o, core.y - d)) # north ray + tiles.append((core.x + o, core.y + 1 + d)) # south ray + return [(x, y) for (x, y) in tiles if 0 <= x < mi.w and 0 <= y < mi.h] + + def _denyHomeSiege(self, ct, mi): + """Pantheon guard-conveyor idea aimed at the core: pre-occupy the + gunner firing tiles around OUR core with 3-Ti barriers so a siege + has nowhere to stand. Denial beats reaction (kdef gates proved + reacting after the siege stands is too late).""" + if ct.get_action_cooldown() != 0 or self.teamCore is None: + return + if ct.get_global_resources() < ct.get_barrier_cost() + 40: + return + pos = ct.get_position() + for (x, y) in self._siegeRayTiles(self.teamCore): + p = Position(x, y) + if pos.distance_squared(p) > 2 or p == pos: + continue + if not ct.is_in_vision(p): + continue + if ct.get_tile_env(p) in (Environment.WALL, + Environment.ORE_TITANIUM): + continue # walls deny for free; ore should get a harvester + if ct.get_tile_building_id(p) is not None: + continue # any building already denies the tile + if ct.can_build_barrier(p): + ct.build_barrier(p) + mi.own_barriers |= mi.bit(x, y) + mi.note_tile(x, y, mapinfo_mod.T_BLOCK) + return + + def _ringGuns(self, ct, mi): + """v85's proven kill move made deliberate: keep up to 3 gunners ON + the enemy core's adjacent ring. A ring gunner one-clips every spawn + (reload 1) and chips the core — their economy stops either way.""" + if ct.get_action_cooldown() != 0 or self.enemyCore is None: + return + ec = self.enemyCore + pos = ct.get_position() + if pos.distance_squared(ec) > 18: + return + if ct.get_global_resources() < ct.get_gunner_cost() + 40: + return + near = sum(1 for (x, y) in mi.own_turrets + if abs(x - ec.x) + abs(y - ec.y) <= 3) + if near >= 3: + return + foot = {(ec.x + a, ec.y + b) for a in (0, 1) for b in (0, 1)} + ring = [(x, y) for x in range(ec.x - 1, ec.x + 3) + for y in range(ec.y - 1, ec.y + 3) + if (x, y) not in foot + and 0 <= x < mi.w and 0 <= y < mi.h] + for (x, y) in sorted(ring, key=lambda t: (pos.x - t[0]) ** 2 + + (pos.y - t[1]) ** 2): + p = Position(x, y) + if pos.distance_squared(p) > 2 or p == pos: + continue + if not ct.is_in_vision(p): + continue + facing = self._cardinalTo(p, Position(ec.x, ec.y)) + if facing == Direction.CENTRE: + continue + if ct.can_build_gunner(p, facing): + ct.build_gunner(p, facing) + mi.own_turrets[(x, y)] = 'G' + return + + def _tryChokeBlock(self, ct, mi): + if ct.get_action_cooldown() != 0 or not mapanalysis.chokes \ + or self.teamCore is None: + return + pos = ct.get_position() + enemyRef = self._enemyCoreGuess() + ti = ct.get_global_resources() + for choke in mapanalysis.chokes: + tile, clearance = choke[0], choke[1] + p = Position(tile[0], tile[1]) + if pos.distance_squared(p) > 2: + continue + if (enemyRef is not None + and manhattan(p, self.teamCore) >= manhattan(p, enemyRef)): + continue + if not self._inBounds(p) or not ct.is_in_vision(p): + continue + if ct.get_tile_building_id(p) is not None: + continue + if ct.get_tile_env(p) == Environment.ORE_TITANIUM: + continue + if clearance <= 1.2: + if ti >= ct.get_barrier_cost() + 30 and ct.can_build_barrier(p): + ct.build_barrier(p) + return + elif ti >= 100 and ct.can_build_launcher(p): + ct.build_launcher(p) + return + + def _healAdjacent(self, ct): + if ct.get_action_cooldown() != 0: + return + pos = ct.get_position() + if ct.get_hp() < ct.get_max_hp() and ct.can_heal(pos): + ct.heal(pos) + return + # Pantheon 6-tier heal priority: (very damaged?, type tier, damage) + TIER = {EntityType.CORE: 6, EntityType.GUNNER: 5, + EntityType.SENTINEL: 5, EntityType.LAUNCHER: 5, + EntityType.HARVESTER: 4, EntityType.CONVEYOR: 3, + EntityType.SPLITTER: 2, EntityType.BARRIER: 2} + best = None + for bid in ct.get_nearby_buildings(2): + if ct.get_team(bid) != ct.get_team(): + continue + dmg = ct.get_max_hp(bid) - ct.get_hp(bid) + if dmg >= 4: + p = ct.get_position(bid) + key = (1 if dmg >= 12 else 0, + TIER.get(ct.get_entity_type(bid), 1), dmg) + if best is None or key > best[0]: + best = (key, p) + if best is not None and ct.can_heal(best[1]): + ct.heal(best[1]) + + def _advanceAnalysis(self, ct): + job = mapanalysis.job + if job is None and ct.get_cpu_time_elapsed() < mapanalysis.CPU_BUDGET_US: + job = mapanalysis.get_job() + while (job is not None + and ct.get_cpu_time_elapsed() < mapanalysis.CPU_BUDGET_US + and job.phase != mapanalysis.DONE): + job.step() + + # ------------------------------------------------------------------ + # Turrets (ported from botv2 — proven handlers) + # ------------------------------------------------------------------ + def sentinel(self, ct: Controller) -> None: + if self.teamCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) == ct.get_team()): + self.teamCore = ct.get_position(bid) + team = ct.get_team() + seesEnemy = False + for eid in ct.get_nearby_entities(): + if ct.get_team(eid) != team: + seesEnemy = True + break + if seesEnemy: + self.idleTurns = 0 + else: + self.idleTurns += 1 + if self.idleTurns > 40 and ct.get_current_round() > 150: + nearHarv = any( + ct.get_team(b) == team + and ct.get_entity_type(b) == EntityType.HARVESTER + for b in ct.get_nearby_buildings(8)) + nearCore = (self.teamCore is not None + and manhattan(ct.get_position(), + self.teamCore) <= 6) + if not nearHarv and not nearCore: + ct.self_destruct() + return + priority = { + EntityType.CORE: 5, EntityType.SENTINEL: 3, EntityType.GUNNER: 3, + EntityType.BARRIER: 2, EntityType.LAUNCHER: 2, + EntityType.CONVEYOR: 1, + } + targets = set(priority) + bestTile, bestKey = None, None + for tile in ct.get_attackable_tiles(): + if not self._inBounds(tile) or not ct.is_in_vision(tile): + continue + buildingID = ct.get_tile_building_id(tile) + builderID = ct.get_tile_builder_bot_id(tile) + if builderID is not None and ct.get_team(builderID) == team: + continue + if buildingID is not None and ct.get_team(buildingID) == team: + continue + tilePriority, targetHp = 0, 0 + if buildingID is not None: + btype = ct.get_entity_type(buildingID) + if btype not in targets: + continue + tilePriority = priority.get(btype, 1) + targetHp = ct.get_hp(buildingID) + if builderID is not None: + tilePriority += 2 + elif builderID is not None: + continue + key = (tilePriority, 1 if 0 < targetHp <= 18 else 0, -targetHp) + if bestTile is None or key > bestKey: + bestKey, bestTile = key, tile + if bestTile is None: + return + # Pantheon one-tap lock: if the target survives one shot and a + # LOWER-id ally sentinel also covers it, hold fire — lower id acts + # first, so next turn both shots land the same round (36 dmg kills + # anything but a core chunk) before enemy medics can heal. Bounded + # wait so a dead ally can't deadlock us. + targetHp = 0 + bid = ct.get_tile_building_id(bestTile) \ + if ct.is_in_vision(bestTile) else None + if bid is not None: + targetHp = ct.get_hp(bid) + if targetHp > 18 and self.tapWait < 2: + myId = ct.get_id() + myPos = ct.get_position() + for aid in ct.get_nearby_entities(): + if (aid < myId and ct.get_team(aid) == ct.get_team() + and ct.get_entity_type(aid) == EntityType.SENTINEL + and ct.get_position(aid).distance_squared(bestTile) + <= 32): + self.tapWait += 1 + return + self.tapWait = 0 + if ct.can_fire(bestTile): + ct.fire(bestTile) + + def gunner(self, ct: Controller) -> None: + target = ct.get_gunner_target() + if target is not None: + builder_id = ct.get_tile_builder_bot_id(target) + building_id = ct.get_tile_building_id(target) + tid = builder_id if builder_id is not None else building_id + if tid is not None and ct.get_team(tid) != ct.get_team(): + if ct.can_fire(target): + ct.fire(target) + return + if ct.get_global_resources() < 60 or ct.get_action_cooldown() != 0: + return + my_pos = ct.get_position() + current = ct.get_direction() + cands = [] + for eid in ct.get_nearby_entities(13): + et = ct.get_entity_type(eid) + if et not in (EntityType.GUNNER, EntityType.SENTINEL, + EntityType.BUILDER_BOT): + continue + if ct.get_team(eid) == ct.get_team(): + continue + ep = ct.get_position(eid) + dx, dy = ep.x - my_pos.x, ep.y - my_pos.y + if dx != 0 and dy != 0: + continue + if dx > 0: + f = Direction.EAST + elif dx < 0: + f = Direction.WEST + elif dy > 0: + f = Direction.SOUTH + elif dy < 0: + f = Direction.NORTH + else: + continue + if f == current: + continue + pr = 0 if et in (EntityType.GUNNER, EntityType.SENTINEL) else 1 + cands.append((pr, my_pos.distance_squared(ep), + CARDINALS.index(f), f)) + if cands: + f = min(cands)[3] + if ct.can_rotate(f): + ct.rotate(f) + + def launcher(self, ct: Controller) -> None: + myLoc = ct.get_position() + if self.teamCore is None: + for bid in ct.get_nearby_buildings(): + if (ct.get_entity_type(bid) == EntityType.CORE + and ct.get_team(bid) == ct.get_team()): + self.teamCore = ct.get_position(bid) + anchor = self.teamCore if self.teamCore is not None else myLoc + team = ct.get_team() + hist = self.enemyBotHistory + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != team + and ct.get_entity_type(uid) == EntityType.BUILDER_BOT): + ep = ct.get_position(uid) + lst = hist.setdefault(uid, []) + if not lst or lst[-1] != ep: + lst.append(ep) + if len(lst) > 12: + lst.pop(0) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx == 0 and dy == 0: + continue + adjacent = Position(myLoc.x + dx, myLoc.y + dy) + if (not self._inBounds(adjacent) + or not ct.is_in_vision(adjacent)): + continue + bot_id = ct.get_tile_builder_bot_id(adjacent) + if bot_id is None or ct.get_team(bot_id) == team: + continue + walked = set(hist.get(bot_id, ())[:-3]) + # Pantheon region-minimizing throw: flood the area the bot + # could navigate from each destination (enemy-POV, unseen = + # impassable) and throw into the SMALLEST pocket. Trail tiles + # (trap: forced retrace) outrank everything. + mi = self.mi + if mi is None: + w, h = ct.get_map_width(), ct.get_map_height() + self.mi = mi = mapinfo_mod.MapInfo(w, h) + mi.update_vision(ct) + epass = mi.seen & ~mi.walls & ~mi.blocked + best, bestKey = None, None + for tile in ct.get_nearby_tiles(26): + if not ct.can_launch(adjacent, tile): + continue + seed = mi.bit(tile.x, tile.y) + region = seed + for _ in range(12): + grown = mi.expand(region) & epass + if grown == region: + break + region = grown + size = bin(region).count('1') + key = (1 if tile in walked else 0, + -size, manhattan(anchor, tile)) + if bestKey is None or key > bestKey: + bestKey, best = key, tile + if best is not None: + ct.launch(adjacent, best) + return + + # ---- misc ---- + def _inBounds(self, p): + mi = self.mi + if mi is None: + return False + return 0 <= p.x < mi.w and 0 <= p.y < mi.h + + @staticmethod + def _cardinalTo(origin, target): + dx = target.x - origin.x + dy = target.y - origin.y + if abs(dx) >= abs(dy) and dx != 0: + return Direction.EAST if dx > 0 else Direction.WEST + if dy != 0: + return Direction.SOUTH if dy > 0 else Direction.NORTH + return Direction.CENTRE diff --git a/artifacts/meta/kfort_d6473f1/bots/kfort/mapanalysis.py b/artifacts/meta/kfort_d6473f1/bots/kfort/mapanalysis.py new file mode 100644 index 0000000..c226589 --- /dev/null +++ b/artifacts/meta/kfort_d6473f1/bots/kfort/mapanalysis.py @@ -0,0 +1,777 @@ +"""chokepoint finder for the shared tile map. + +one team-wide resumable job. builders call note_structural_tile only on +passable<->blocking changes, dupes get coalesced here. results are plain +(x, y) tuples so this file doesnt need fcode. + +smoke test, two rooms + a 1 wide corridor:: + + >>> grid = [[2] * 20 for _ in range(20)] + >>> for x in range(1, 8): + ... for y in range(3, 17): grid[x][y] = 0 + >>> for x in range(12, 19): + ... for y in range(3, 17): grid[x][y] = 0 + >>> for x in range(8, 12): grid[x][10] = 0 + >>> result, _ = analyze_for_test(grid) + >>> len(result) + 1 + >>> result[0][:2] + ((10, 10), 1) + >>> open_map = [[0] * 20 for _ in range(20)] + >>> analyze_for_test(open_map)[0] + [] + +maps are map[x][y] like Player.fullMap. +""" + +from collections import deque + + +REGION_MIN_RADIUS = 3 # min clearance for a big-room region node +MAX_CHOKE_RADIUS = 2 # widest thing we still call a choke +RATIO_SMALL = 0.7 # choke/smaller-room ratio past which rooms just merge +RATIO_LARGE = 0.5 # same but vs the bigger room +RATIO_TWO_CHOKE = 0.8 # collapse 2-exit sliver regions with a wide exit +CPU_BUDGET_US = 6000 # no analysis cpu past this point in a turn +STEP_SLICE = 200 # max queue items per step() slice + +CLEARANCE = 0 +SKELETON = 1 +PRUNE = 2 +REGIONS = 3 +CHOKES = 4 +MERGE = 5 +DONE = 6 + + +struct_version = 0 +chokes = [] +region_partition = [] +region_nodes = [] +job = None +_debug_items = [] +_debug_cursor = 0 + +_map_width = 0 +_map_height = 0 +_structural_map = [] + + +def _index(x, y, height): + return x * height + y + + +def configure_map(width, height): + """Initialise the shared structural map; unexplored tiles are blocking.""" + global _map_width, _map_height, _structural_map, struct_version, job + global chokes, region_partition, region_nodes, _debug_items, _debug_cursor + if width == _map_width and height == _map_height and _structural_map: + return + _map_width = width + _map_height = height + _structural_map = [True] * (width * height) + struct_version += 1 + job = None + chokes = [] + region_partition = [] + region_nodes = [] + _debug_items = [] + _debug_cursor = 0 + + +def reset_map(width, height): + """Start a fresh match even when its dimensions match the previous one.""" + global _map_width, _map_height, _structural_map, struct_version, job + global chokes, region_partition, region_nodes, _debug_items, _debug_cursor + _map_width = width + _map_height = height + _structural_map = [True] * (width * height) + struct_version += 1 + job = None + chokes = [] + region_partition = [] + region_nodes = [] + _debug_items = [] + _debug_cursor = 0 + + +def note_structural_tile(x, y, blocking, width, height): + """Record one structural observation and bump the version only if needed.""" + global struct_version, job, chokes, region_partition, region_nodes + global _debug_items, _debug_cursor + configure_map(width, height) + idx = _index(x, y, height) + blocking = bool(blocking) + if _structural_map[idx] == blocking: + return False + _structural_map[idx] = blocking + struct_version += 1 + job = None + chokes = [] + region_partition = [] + region_nodes = [] + _debug_items = [] + _debug_cursor = 0 + return True + + +def get_job(): + """Return the current-version singleton job, creating it lazily.""" + global job + if not _structural_map: + return None + if job is None or job.version != struct_version: + job = AnalysisJob( + _map_width, _map_height, _structural_map, struct_version + ) + return job + + +def region_at(x, y): + """Return the published region id for a tile, or -1 if none is ready.""" + if (not region_partition or x < 0 or y < 0 + or x >= _map_width or y >= _map_height): + return -1 + return region_partition[_index(x, y, _map_height)] + + +def draw_debug(ct, position_factory, deadline_us=9000): + """Draw the published red chokes and green nodes in bounded spare slices.""" + global _debug_cursor + items = _debug_items + while (_debug_cursor < len(items) + and ct.get_cpu_time_elapsed() < deadline_us): + tile, red, green, blue = items[_debug_cursor] + _debug_cursor += 1 + ct.draw_indicator_dot(position_factory(tile[0], tile[1]), + red, green, blue) + + +class AnalysisJob: + """Small-slice phase machine; callers enforce the per-turn CPU deadline.""" + + def __init__(self, width, height, blocking, version): + self.width = width + self.height = height + self.count = width * height + self.blocking = blocking[:] + self.version = version + self.phase = CLEARANCE + + self.clearance = [-1] * self.count + self.skeleton = [False] * self.count + self.adj = [[] for _ in range(self.count)] + self.active = [False] * self.count + self.degree = [0] * self.count + self.raw_region_nodes = [] + self.region_index = [-1] * self.count + self.candidates = [] + + self._mode = "seed" + self._cursor = 0 + self._queue = deque() + self._candidate = -1 + self._candidate_scan = 0 + self._candidate_end = 0 + self._candidate_greater = False + self._region_cursor = 0 + self._neighbor_cursor = 0 + self._edge_seen = set() + self._walk = None + + self.parent = [] + self.root_clearance = [] + self.root_size = [] + self.keep = [] + self.partition = [] + self._partition_clearance = [] + self._partition_size = [] + self._merge_cursor = 0 + self._ratio_buckets = [] + self._ratio_bucket = -1 + self._ratio_bucket_pos = 0 + self._incidence = [] + self._merge_changed = False + self._output = [] + + def step(self): + if self.phase == CLEARANCE: + self._step_clearance() + elif self.phase == SKELETON: + self._step_skeleton() + elif self.phase == PRUNE: + self._step_prune() + elif self.phase == REGIONS: + self._step_regions() + elif self.phase == CHOKES: + self._step_chokes() + elif self.phase == MERGE: + self._step_merge() + + def _neighbors(self, idx): + h = self.height + w = self.width + x, y = divmod(idx, h) + if x: + yield idx - h + if x + 1 < w: + yield idx + h + if y: + yield idx - 1 + if y + 1 < h: + yield idx + 1 + + def _step_clearance(self): + limit = STEP_SLICE + n = self.count + if self._mode == "seed": + end = min(n, self._cursor + limit) + for idx in range(self._cursor, end): + if self.blocking[idx]: + self.clearance[idx] = 0 + self._queue.append(idx) + self._cursor = end + if end == n: + self._mode = "bfs" if self._queue else "unbounded" + self._cursor = 0 + return + + if self._mode == "unbounded": + end = min(n, self._cursor + limit) + far = self.width + self.height + 1 + for idx in range(self._cursor, end): + if not self.blocking[idx]: + self.clearance[idx] = far + self._cursor = end + if end == n: + self.phase = SKELETON + self._mode = "mark" + self._cursor = 0 + return + + pops = 0 + clearance = self.clearance + queue = self._queue + while queue and pops < limit: + idx = queue.popleft() + next_distance = clearance[idx] + 1 + for other in self._neighbors(idx): + if clearance[other] < 0: + clearance[other] = next_distance + queue.append(other) + pops += 1 + if not queue: + self.phase = SKELETON + self._mode = "mark" + self._cursor = 0 + + def _step_skeleton(self): + n = self.count + end = min(n, self._cursor + STEP_SLICE) + if self._mode == "mark": + clearance = self.clearance + blocking = self.blocking + skeleton = self.skeleton + for idx in range(self._cursor, end): + if blocking[idx]: + continue + value = clearance[idx] + is_maximum = True + for other in self._neighbors(idx): + if clearance[other] > value: + is_maximum = False + break + skeleton[idx] = is_maximum + self._cursor = end + if end == n: + self._mode = "connect" + self._cursor = 0 + return + + skeleton = self.skeleton + adj = self.adj + h = self.height + w = self.width + for idx in range(self._cursor, end): + if not skeleton[idx]: + continue + x, y = divmod(idx, h) + if x + 1 < w and skeleton[idx + h]: + adj[idx].append(idx + h) + adj[idx + h].append(idx) + if y + 1 < h and skeleton[idx + 1]: + adj[idx].append(idx + 1) + adj[idx + 1].append(idx) + self._cursor = end + if end == n: + self.phase = PRUNE + self._mode = "init" + self._cursor = 0 + + def _step_prune(self): + if self._mode == "init": + end = min(self.count, self._cursor + STEP_SLICE) + for idx in range(self._cursor, end): + if self.skeleton[idx]: + self.active[idx] = True + degree = len(self.adj[idx]) + self.degree[idx] = degree + if degree == 1: + self._queue.append(idx) + self._cursor = end + if end == self.count: + self._mode = "peel" + return + + pops = 0 + while self._queue and pops < STEP_SLICE: + idx = self._queue.popleft() + pops += 1 + if not self.active[idx] or self.degree[idx] != 1: + continue + neighbor = -1 + for other in self.adj[idx]: + if self.active[other]: + neighbor = other + break + if neighbor < 0: + continue + if self.clearance[idx] < self.clearance[neighbor]: + self.active[idx] = False + self.degree[idx] = 0 + self.degree[neighbor] -= 1 + if self.degree[neighbor] == 1: + self._queue.append(neighbor) + if not self._queue: + self.phase = REGIONS + self._mode = "scan" + self._cursor = 0 + + def _finish_region_candidate(self): + if not self._candidate_greater: + idx = self._candidate + self.region_index[idx] = len(self.raw_region_nodes) + self.raw_region_nodes.append(idx) + self._candidate = -1 + + def _step_regions(self): + ops = 0 + h = self.height + w = self.width + while ops < STEP_SLICE: + if self._candidate >= 0: + idx = self._candidate + x, y = divmod(idx, h) + radius = self.clearance[idx] + while self._candidate_scan < self._candidate_end and ops < STEP_SLICE: + pos = self._candidate_scan + self._candidate_scan += 1 + ops += 1 + ox, oy = divmod(pos, h) + if (abs(ox - x) <= radius and abs(oy - y) <= radius + and self.active[pos] + and self.clearance[pos] > radius): + self._candidate_greater = True + self._candidate_scan = self._candidate_end + if self._candidate_scan >= self._candidate_end: + self._finish_region_candidate() + continue + + if self._cursor >= self.count: + self.phase = CHOKES + self._mode = "walk" + self._region_cursor = 0 + self._neighbor_cursor = 0 + return + + idx = self._cursor + self._cursor += 1 + ops += 1 + if not self.active[idx]: + continue + degree = self.degree[idx] + if degree != 2: + self.region_index[idx] = len(self.raw_region_nodes) + self.raw_region_nodes.append(idx) + elif self.clearance[idx] >= REGION_MIN_RADIUS: + radius = self.clearance[idx] + x, y = divmod(idx, h) + x0 = max(0, x - radius) + x1 = min(w - 1, x + radius) + y0 = max(0, y - radius) + y1 = min(h - 1, y + radius) + # scan the enclosing flat range; Chebyshev bounds are checked above + self._candidate = idx + self._candidate_scan = x0 * h + y0 + self._candidate_end = x1 * h + y1 + 1 + self._candidate_greater = False + + def _edge_key(self, left, right): + if left > right: + left, right = right, left + return left * self.count + right + + def _outside_seed(self, endpoint, inward): + """Step off a local-max plateau toward its adjacent open region.""" + best = endpoint + best_clearance = -1 + for other in self._neighbors(endpoint): + if other == inward or self.blocking[other]: + continue + value = self.clearance[other] + if value > best_clearance: + best = other + best_clearance = value + return best + + def _begin_next_walk(self): + nodes = self.raw_region_nodes + while self._region_cursor < len(nodes): + start = nodes[self._region_cursor] + neighbors = self.adj[start] + while self._neighbor_cursor < len(neighbors): + other = neighbors[self._neighbor_cursor] + self._neighbor_cursor += 1 + if not self.active[other]: + continue + key = self._edge_key(start, other) + if key in self._edge_seen: + continue + self._edge_seen.add(key) + start_region = self.region_index[start] + values = [(self.clearance[start], start), + (self.clearance[other], other)] + minimum = min(values)[0] + minima = [tile for value, tile in values if value == minimum] + self._walk = [start_region, start, other, minimum, minima, + [start, other]] + return True + self._region_cursor += 1 + self._neighbor_cursor = 0 + return False + + def _step_chokes(self): + ops = 0 + while ops < STEP_SLICE: + if self._walk is None: + if not self._begin_next_walk(): + self.phase = MERGE + self._mode = "partition_raw_init" + return + start_region, previous, current, minimum, minima, path = self._walk + ops += 1 + current_region = self.region_index[current] + if current_region >= 0 and current_region != start_region: + if minimum <= MAX_CHOKE_RADIUS: + # prefer the middle of the minimum-clearance run + chosen = minima[len(minima) // 2] + left_pos = self._outside_seed(path[0], path[1]) + right_pos = self._outside_seed(path[-1], path[-2]) + self.candidates.append( + [minimum, chosen, start_region, current_region, + left_pos, right_pos] + ) + self._walk = None + continue + + next_tile = -1 + for other in self.adj[current]: + if self.active[other] and other != previous: + next_tile = other + break + if next_tile < 0: + self._walk = None + continue + key = self._edge_key(current, next_tile) + if key in self._edge_seen: + self._walk = None + continue + self._edge_seen.add(key) + value = self.clearance[next_tile] + if value < minimum: + minimum = value + minima = [next_tile] + elif value == minimum: + minima.append(next_tile) + path.append(next_tile) + self._walk = [start_region, current, next_tile, minimum, minima, path] + + def _start_partition(self, removed): + self.partition = [-1] * self.count + self._removed = removed + self._partition_cursor = 0 + self._component = -1 + self._queue.clear() + self._partition_clearance = [] + self._partition_size = [] + + def _step_partition(self, next_mode): + ops = 0 + while ops < STEP_SLICE: + if self._queue: + idx = self._queue.popleft() + ops += 1 + self._partition_size[self._component] += 1 + value = self.clearance[idx] + if value > self._partition_clearance[self._component]: + self._partition_clearance[self._component] = value + for other in self._neighbors(idx): + if (self.partition[other] < 0 and not self.blocking[other] + and other not in self._removed): + self.partition[other] = self._component + self._queue.append(other) + continue + while self._partition_cursor < self.count: + idx = self._partition_cursor + self._partition_cursor += 1 + ops += 1 + if (not self.blocking[idx] and idx not in self._removed + and self.partition[idx] < 0): + self._component += 1 + self._partition_clearance.append(0) + self._partition_size.append(0) + self.partition[idx] = self._component + self._queue.append(idx) + break + if ops >= STEP_SLICE: + return False + if self._partition_cursor >= self.count and not self._queue: + self._mode = next_mode + return True + return False + + def _find(self, node): + parent = self.parent + while parent[node] != node: + parent[node] = parent[parent[node]] + node = parent[node] + return node + + def _union(self, left, right): + left = self._find(left) + right = self._find(right) + if left == right: + return left + lc = self.root_clearance[left] + rc = self.root_clearance[right] + if rc > lc or (rc == lc and right < left): + left, right = right, left + self.parent[right] = left + self.root_clearance[left] = max(lc, rc) + self.root_size[left] += self.root_size[right] + return left + + def _candidate_components(self, candidate): + left = self.partition[candidate[4]] + right = self.partition[candidate[5]] + if left < 0: + left = self.partition[self.raw_region_nodes[candidate[2]]] + if right < 0: + right = self.partition[self.raw_region_nodes[candidate[3]]] + return left, right + + def _step_merge(self): + if self._mode == "partition_raw_init": + self._removed = set() + self._merge_cursor = 0 + self._mode = "partition_raw_remove" + return + if self._mode == "partition_raw_remove": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + self._removed.add(self.candidates[idx][1]) + self._merge_cursor = end + if end == len(self.candidates): + self._start_partition(self._removed) + self._mode = "partition_raw" + return + if self._mode == "partition_raw": + self._step_partition("ratio_init") + return + if self._mode == "ratio_init": + count = len(self._partition_size) + self.parent = list(range(count)) + self.root_clearance = self._partition_clearance[:] + self.root_size = self._partition_size[:] + self.keep = [False] * len(self.candidates) + self._ratio_buckets = [ + [] for _ in range(MAX_CHOKE_RADIUS + 1) + ] + self._merge_cursor = 0 + self._mode = "ratio_order" + return + if self._mode == "ratio_order": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + clearance = self.candidates[idx][0] + self._ratio_buckets[clearance].append(idx) + self._merge_cursor = end + if end == len(self.candidates): + self._ratio_bucket = MAX_CHOKE_RADIUS + self._ratio_bucket_pos = 0 + self._mode = "ratio" + return + if self._mode == "ratio": + ops = 0 + while self._ratio_bucket >= 0 and ops < STEP_SLICE: + bucket = self._ratio_buckets[self._ratio_bucket] + if self._ratio_bucket_pos >= len(bucket): + self._ratio_bucket -= 1 + self._ratio_bucket_pos = 0 + continue + candidate_index = bucket[self._ratio_bucket_pos] + self._ratio_bucket_pos += 1 + ops += 1 + candidate = self.candidates[candidate_index] + left, right = self._candidate_components(candidate) + if left < 0 or right < 0: + continue + left = self._find(left) + right = self._find(right) + if left == right: + continue + choke_clearance = candidate[0] + small = min(self.root_clearance[left], self.root_clearance[right]) + large = max(self.root_clearance[left], self.root_clearance[right]) + if (choke_clearance > RATIO_SMALL * small + or choke_clearance > RATIO_LARGE * large): + self._union(left, right) + else: + self.keep[candidate_index] = True + if self._ratio_bucket < 0: + self._mode = "incidence_init" + return + if self._mode == "incidence_init": + self._incidence = [[] for _ in self.parent] + self._merge_cursor = 0 + self._mode = "incidence" + return + if self._mode == "incidence": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + if not self.keep[idx]: + continue + left, right = self._candidate_components(self.candidates[idx]) + if left < 0 or right < 0: + self.keep[idx] = False + continue + left = self._find(left) + right = self._find(right) + if left == right: + self.keep[idx] = False + continue + self._incidence[left].append(idx) + self._incidence[right].append(idx) + self._merge_cursor = end + if end == len(self.candidates): + self._merge_cursor = 0 + self._merge_changed = False + self._mode = "two_choke" + return + if self._mode == "two_choke": + end = min(len(self.parent), self._merge_cursor + STEP_SLICE) + for region in range(self._merge_cursor, end): + if self._find(region) != region: + continue + exits = self._incidence[region] + if len(exits) != 2: + continue + wider = max(exits, key=lambda i: self.candidates[i][0]) + if (self.candidates[wider][0] + > RATIO_TWO_CHOKE * self.root_clearance[region]): + left, right = self._candidate_components(self.candidates[wider]) + self._union(left, right) + self.keep[wider] = False + self._merge_changed = True + break + self._merge_cursor = end + if self._merge_changed: + self._mode = "incidence_init" + elif end == len(self.parent): + self._mode = "final_partition_init" + return + if self._mode == "final_partition_init": + self._removed = set() + self._merge_cursor = 0 + self._mode = "final_partition_remove" + return + if self._mode == "final_partition_remove": + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + if self.keep[idx]: + self._removed.add(self.candidates[idx][1]) + self._merge_cursor = end + if end == len(self.candidates): + self._start_partition(self._removed) + self._mode = "final_partition" + return + if self._mode == "final_partition": + self._step_partition("publish_init") + return + if self._mode == "publish_init": + self._output = [] + self._merge_cursor = 0 + self._mode = "publish" + return + if self._mode == "publish": + self._step_publish() + return + if self._mode == "debug_publish": + self._step_debug_publish() + + def _step_publish(self): + global chokes, region_partition, region_nodes + global _debug_items, _debug_cursor + h = self.height + end = min(len(self.candidates), self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + candidate = self.candidates[idx] + if not self.keep[idx]: + continue + left = self.partition[candidate[4]] + right = self.partition[candidate[5]] + if left < 0 or right < 0 or left == right: + continue + tile = divmod(candidate[1], h) + self._output.append((tile, candidate[0], + self._partition_size[left], + self._partition_size[right])) + self._merge_cursor = end + if end == len(self.candidates): + chokes = self._output[:] + region_partition = self.partition[:] + region_nodes = [] + _debug_items = [(choke[0], 255, 0, 0) for choke in chokes] + _debug_cursor = 0 + self._merge_cursor = 0 + self._mode = "debug_publish" + + def _step_debug_publish(self): + global region_nodes, _debug_items + end = min(len(self.raw_region_nodes), + self._merge_cursor + STEP_SLICE) + for idx in range(self._merge_cursor, end): + node = divmod(self.raw_region_nodes[idx], self.height) + region_nodes.append(node) + _debug_items.append((node, 0, 255, 0)) + self._merge_cursor = end + if end == len(self.raw_region_nodes): + self.phase = DONE + + +def analyze_for_test(full_map): + """Synchronously analyze an ``[x][y]`` synthetic map for doctests/tests.""" + if not full_map or not full_map[0]: + return [], [] + width = len(full_map) + height = len(full_map[0]) + blocking = [] + for x in range(width): + if len(full_map[x]) != height: + raise ValueError("full_map must be rectangular and indexed [x][y]") + for y in range(height): + blocking.append(full_map[x][y] in (-1, 2, 3)) + test_job = AnalysisJob(width, height, blocking, 0) + while test_job.phase != DONE: + test_job.step() + return chokes[:], region_partition[:] diff --git a/artifacts/meta/kfort_d6473f1/bots/kfort/mapinfo.py b/artifacts/meta/kfort_d6473f1/bots/kfort/mapinfo.py new file mode 100644 index 0000000..66dc0e5 --- /dev/null +++ b/artifacts/meta/kfort_d6473f1/bots/kfort/mapinfo.py @@ -0,0 +1,301 @@ +"""Bitmask map-info layer (Pantheon/Khaos rebuild). + +The whole map lives in Python big-int masks: bit n = x + y*W. Geometric +neighbour expansion is 4 shifts; derived masks (threat, placeable, frontier) +are boolean algebra over whole boards. On <=30x30 maps a board is <=900 bits. + +Static knowledge (seen/walls/ore) accumulates and is shared through the comm +store with the same 12-bit tile protocol botv2 uses (slots 1..6, 16 tiles per +broadcast, rotation keyed on slot 0's spawn counter). + +>>> m = MapInfo(4, 3) +>>> b = m.bit(1, 1) +>>> sorted(m.xy(t) for t in m.iter_bits(m.expand(b) & ~b)) +[(0, 1), (1, 0), (1, 2), (2, 1)] +>>> m.xy(m.east(b)) +(2, 1) +>>> m.east(m.bit(3, 1)) # east edge does not wrap +0 +""" + +from fcode import Direction, EntityType, Environment, Position + +DIR_DELTA = { + Direction.NORTH: (0, -1), Direction.SOUTH: (0, 1), + Direction.EAST: (1, 0), Direction.WEST: (-1, 0), +} + +# tile type codes shared through the store (identical to botv2) +T_EMPTY, T_ORE, T_WALL, T_BLOCK = 0, 1, 2, 3 + +GUNNER_RAY = 3 # threat approximation: gunner cross length +SENT_BAND = 5 # sentinel band length (3 wide) + + +class MapInfo: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.n = w * h + self.board = (1 << self.n) - 1 + col0 = 0 + for y in range(h): + col0 |= 1 << (y * w) + self.not_col0 = self.board & ~col0 + self.not_colL = self.board & ~(col0 << (w - 1)) + + # accumulated static knowledge + self.seen = 0 + self.walls = 0 + self.ore = 0 + self.blocked = 0 # non-walkable buildings (shared, any team) + self.struct_version = 0 + + # remembered point features (local to this unit, refreshed on sight) + self.enemy_turrets = {} # (x,y) -> 'G' | 'S' + self.enemy_buildings = {} # (x,y) -> EntityType + self.own_harvesters = {} # (x,y) -> True + self.own_turrets = {} # (x,y) -> True (death detection) + self.turret_losses = [] # tiles where an own turret just died + self._threat_cache = (None, 0) + + # per-turn dynamic masks + self.own_bots = 0 + self.enemy_bots = 0 + self.own_conveyors = 0 + self.own_barriers = 0 # break-walkable at cost (Pantheon 15) + self.own_loaded = 0 # own conveyors holding a stack (jam-avoid) + # remembered facings of own conveyors: (x,y) -> (dx,dy) output delta + self.own_conv_facing = {} + + # outgoing share queue: list of (x, y, code) + self.out_tiles = [] + # optional hook: called (x, y, blocking) on structural changes so the + # chokepoint analyzer can invalidate incrementally + self.on_block_change = None + + # ---- geometry ---- + def bit(self, x: int, y: int) -> int: + return 1 << (x + y * self.w) + + def xy(self, single_bit: int): + n = single_bit.bit_length() - 1 + return n % self.w, n // self.w + + def east(self, m): + return (m << 1) & self.not_col0 + + def west(self, m): + return (m >> 1) & self.not_colL + + def south(self, m): + return (m << self.w) & self.board + + def north(self, m): + return m >> self.w + + def expand(self, m): + return m | self.east(m) | self.west(m) | self.south(m) | self.north(m) + + def iter_bits(self, m): + while m: + lsb = m & -m + yield lsb + m ^= lsb + + # ---- derived masks ---- + def passable(self): + """Tiles a builder could stand on (unknown treated optimistically).""" + return self.board & ~self.walls & ~self.blocked + + def threat(self): + """Soft-threat mask from remembered enemy turrets (facing unknown: + gunner = cardinal cross len 3, sentinel = 3-wide bands len 5).""" + key = tuple(sorted(self.enemy_turrets.items())) + cached, ver = self._threat_cache + if cached is not None and ver == key: + return cached + t = 0 + shifts = (self.east, self.west, self.north, self.south) + for (x, y), kind in self.enemy_turrets.items(): + b = self.bit(x, y) + if kind == 'G': + for shift in shifts: + ray = b + for _ in range(GUNNER_RAY): + ray = shift(ray) & ~self.walls + t |= ray + else: + t |= self.expand(b) + for shift in shifts: + ray = b + acc = 0 + for _ in range(SENT_BAND): + ray = shift(ray) & ~self.walls + acc |= ray + t |= self.expand(acc) # widen the ray into a 3-wide band + t &= self.board + self._threat_cache = (t, key) + return t + + def frontier(self): + """Seen tiles adjacent to unseen ones — exploration targets.""" + unseen = self.board & ~self.seen + return self.expand(unseen) & self.seen & ~self.walls + + # ---- knowledge updates ---- + def note_tile(self, x, y, code, share=True): + b = self.bit(x, y) + old_wall = self.walls & b + old_block = self.blocked & b + newly = not (self.seen & b) + self.seen |= b + if code == T_WALL: + self.walls |= b + elif code == T_BLOCK: + self.blocked |= b + else: + changed = old_block != 0 + self.blocked &= ~b + if code == T_ORE: + self.ore |= b + if changed: + self.struct_version += 1 + if self.on_block_change: + self.on_block_change(x, y, False) + if (code in (T_WALL, T_BLOCK)) and not (old_wall or old_block): + self.struct_version += 1 + if self.on_block_change: + self.on_block_change(x, y, True) + if share and (newly or (code == T_BLOCK) != bool(old_block)): + self.out_tiles.append((x, y, code)) + if len(self.out_tiles) > 96: + del self.out_tiles[:32] + + def update_vision(self, ct): + """Per-turn scan: static tiles into masks, dynamic entities rebuilt. + + Vision-delta optimization (Pantheon update_move): env is immutable, so + for already-seen tiles we skip get_tile_env entirely — walls skip ALL + queries (nothing can stand on them), other seen tiles derive env from + the ore mask and only pay the building-id query.""" + my_team = ct.get_team() + get_env = ct.get_tile_env + get_bid = ct.get_tile_building_id + note = self.note_tile + self.own_bots = 0 + self.enemy_bots = 0 + self.own_conveyors = 0 + self.own_barriers = 0 + self.own_loaded = 0 # own conveyors currently holding a stack + get_stored = ct.get_stored_resource + WALKABLE = (EntityType.CONVEYOR, EntityType.SPLITTER) + seen = self.seen + walls = self.walls + ore = self.ore + for tile in ct.get_nearby_tiles(): + x, y = tile.x, tile.y + b = 1 << (x + y * self.w) + if seen & b: + if walls & b: + continue # immutable, uninhabitable: zero queries + env = (Environment.ORE_TITANIUM if ore & b + else Environment.EMPTY) + else: + env = get_env(tile) + if env == Environment.WALL: + note(x, y, T_WALL) + continue + bid = get_bid(tile) + if bid is None: + note(x, y, T_ORE if env == Environment.ORE_TITANIUM else T_EMPTY) + self.enemy_buildings.pop((x, y), None) + self.enemy_turrets.pop((x, y), None) + self.own_harvesters.pop((x, y), None) + if self.own_turrets.pop((x, y), None): + self.turret_losses.append((x, y)) + self.own_conv_facing.pop((x, y), None) + continue + etype = ct.get_entity_type(bid) + team = ct.get_team(bid) + if etype in WALKABLE: + note(x, y, T_ORE if env == Environment.ORE_TITANIUM else T_EMPTY) + if team == my_team: + self.own_conveyors |= self.bit(x, y) + if get_stored(bid) is not None: + self.own_loaded |= self.bit(x, y) + if etype == EntityType.CONVEYOR: + d = DIR_DELTA.get(ct.get_direction(bid)) + if d is not None: + self.own_conv_facing[(x, y)] = d + else: + self.enemy_buildings[(x, y)] = etype + continue + note(x, y, T_BLOCK) + if team != my_team: + self.enemy_buildings[(x, y)] = etype + if etype == EntityType.GUNNER: + self.enemy_turrets[(x, y)] = 'G' + elif etype == EntityType.SENTINEL: + self.enemy_turrets[(x, y)] = 'S' + else: + self.enemy_buildings.pop((x, y), None) + if etype == EntityType.HARVESTER: + self.own_harvesters[(x, y)] = True + elif etype == EntityType.BARRIER: + self.own_barriers |= self.bit(x, y) + elif etype == EntityType.SENTINEL: + self.own_turrets[(x, y)] = 'S' + elif etype in (EntityType.GUNNER, EntityType.LAUNCHER): + self.own_turrets[(x, y)] = True + for uid in ct.get_nearby_units(): + if ct.get_entity_type(uid) != EntityType.BUILDER_BOT: + continue + p = ct.get_position(uid) + if ct.get_team(uid) == my_team: + self.own_bots |= self.bit(p.x, p.y) + else: + self.enemy_bots |= self.bit(p.x, p.y) + + # ---- comm store share (botv2 12-bit protocol, slots 1..6) ---- + def share(self, ct, my_num: int, core: Position): + spawn_count = ct.read_store(0) + if spawn_count <= 0 or my_num < 1 or core is None: + return + if ct.get_current_round() % spawn_count + 1 != my_num: + return + self.out_tiles.sort( + key=lambda t: abs(t[0] - core.x) + abs(t[1] - core.y), + reverse=True) + combined = 0 + for i in range(16): + if i < len(self.out_tiles): + x, y, code = self.out_tiles[i] + val = ((x & 0x1F) << 7) | ((y & 0x1F) << 2) | (code & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val + for i in range(6): + ct.write_store(6 - i, (combined >> (32 * i)) & 0xFFFFFFFF) + del self.out_tiles[:16] + + def absorb(self, ct): + combined = 0 + for i in range(1, 7): + combined = (combined << 32) | ct.read_store(i) + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + if val == 0xFFF: + continue + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + if x >= self.w or y >= self.h: + continue + self.note_tile(x, y, val & 0x3, share=False) + self.out_tiles = [t for t in self.out_tiles + if not (t[0] == x and t[1] == y)] + + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/artifacts/meta/kfort_d6473f1/bots/kfort/path.py b/artifacts/meta/kfort_d6473f1/bots/kfort/path.py new file mode 100644 index 0000000..7ed41d3 --- /dev/null +++ b/artifacts/meta/kfort_d6473f1/bots/kfort/path.py @@ -0,0 +1,173 @@ +"""Bitmask pathfinding (Pantheon/Khaos rebuild). + +All searches operate on MapInfo big-int masks. The Dial's variant runs in +REVERSE from a target mask with a circular array of frontier bitmasks as the +bucket queue (Khaos figure 12): plain step cost 1, stepping into soft threat ++THREAT, so paths detour around enemy turret envelopes unless forced. + +dist_field() unrolls the same search into a per-tile int list for gradient +consumers (conveyor routing). +""" + +THREAT_EXTRA = 12 # entering a threat tile costs 1 + THREAT_EXTRA +BARRIER_EXTRA = 14 # entering an OWN barrier costs 1 + 14 (Pantheon 15: + # the mover destroys it, spending its action) +RING = THREAT_EXTRA + BARRIER_EXTRA + 3 # ring must exceed max edge cost + + +def next_step(mi, start_xy, targets_mask, avoid_mask=0, max_iters=None): + """Reverse Dial's from targets; returns (dx, dy) for the best first step + from start, or None if unreachable. avoid_mask tiles are impassable. + Own barriers are break-walkable at +BARRIER_EXTRA.""" + barriers = mi.own_barriers & ~avoid_mask + passable = (mi.passable() | barriers) & ~avoid_mask + targets = targets_mask & mi.board + if not targets: + return None + threat = mi.threat() + sx, sy = start_xy + sbit = mi.bit(sx, sy) + nbrs = [] + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = sx + dx, sy + dy + if 0 <= nx < mi.w and 0 <= ny < mi.h: + nbrs.append((mi.bit(nx, ny), dx, dy)) + nbr_all = 0 + for b, _, _ in nbrs: + nbr_all |= b + # the start tile itself counts as reachable ground for the wavefront + passable |= sbit | targets + + ring = [0] * RING + ring[0] = targets + visited = 0 + found = {} + if max_iters is None: + max_iters = (mi.w + mi.h) * (THREAT_EXTRA + 1) + 5 + for dist in range(max_iters): + cur = ring[dist % RING] & ~visited + ring[dist % RING] = 0 + if not cur: + if not any(ring): + break + continue + visited |= cur + hit = cur & nbr_all + if hit: + for b, dx, dy in nbrs: + if (hit & b) and b not in found: + found[b] = dist + if len(found) == len(nbrs): + break + nxt = mi.expand(cur) & passable & ~visited + plain = nxt & ~threat & ~barriers + hot = nxt & threat & ~barriers + barr = nxt & barriers & ~threat + both = nxt & barriers & threat + if plain: + ring[(dist + 1) % RING] |= plain + if hot: + ring[(dist + 1 + THREAT_EXTRA) % RING] |= hot + if barr: + ring[(dist + 1 + BARRIER_EXTRA) % RING] |= barr + if both: + ring[(dist + 1 + THREAT_EXTRA + BARRIER_EXTRA) % RING] |= both + if not found: + return None + best = min(found.items(), key=lambda kv: kv[1])[0] + for b, dx, dy in nbrs: + if b == best: + return (dx, dy) + return None + + +def dist_field(mi, targets_mask, avoid_mask=0): + """Full-map cost-to-target list indexed [x + y*w]; 4096 = unreachable.""" + barriers = mi.own_barriers & ~avoid_mask + passable = (mi.passable() | barriers + | (targets_mask & mi.board)) & ~avoid_mask + threat = mi.threat() + field = [4096] * mi.n + ring = [0] * RING + ring[0] = targets_mask & mi.board + visited = 0 + max_iters = (mi.w + mi.h) * (THREAT_EXTRA + 1) + 5 + for dist in range(max_iters): + cur = ring[dist % RING] & ~visited + ring[dist % RING] = 0 + if not cur: + if not any(ring): + break + continue + visited |= cur + m = cur + while m: + lsb = m & -m + field[lsb.bit_length() - 1] = dist + m ^= lsb + nxt = mi.expand(cur) & passable & ~visited + plain = nxt & ~threat & ~barriers + hot = nxt & threat & ~barriers + barr = nxt & barriers & ~threat + both = nxt & barriers & threat + if plain: + ring[(dist + 1) % RING] |= plain + if hot: + ring[(dist + 1 + THREAT_EXTRA) % RING] |= hot + if barr: + ring[(dist + 1 + BARRIER_EXTRA) % RING] |= barr + if both: + ring[(dist + 1 + THREAT_EXTRA + BARRIER_EXTRA) % RING] |= both + return field + + +def closest(mi, start_mask, candidates_mask, avoid_mask=0, max_steps=None): + """Unweighted BFS over passable tiles; returns the candidate bit reached + first (lowest bit as tiebreak) or 0.""" + passable = mi.passable() & ~avoid_mask + front = start_mask & mi.board + visited = front + cands = candidates_mask & mi.board + if front & cands: + hit = front & cands + return hit & -hit + if max_steps is None: + max_steps = mi.w + mi.h + for _ in range(max_steps): + front = mi.expand(front) & (passable | cands) & ~visited + if not front: + return 0 + visited |= front + hit = front & cands + if hit: + return hit & -hit + return 0 + + +def claims(mi, my_bit, others_mask, candidates_mask, max_steps=None): + """Khaos claim partition: simultaneous competing floodfills. Returns the + subset of candidates MY region reaches strictly first (ties -> others, + keeping the claim conservative).""" + passable = mi.passable() + cands = candidates_mask & mi.board + if not cands: + return 0 + my_front = my_bit + other_front = others_mask & mi.board + claimed = my_front | other_front + mine = my_front & cands + cands &= ~other_front + if max_steps is None: + max_steps = mi.w + mi.h + remaining = cands & ~mine + for _ in range(max_steps): + if not remaining or not (my_front or other_front): + break + other_front = mi.expand(other_front) & (passable | cands) & ~claimed + claimed |= other_front + remaining &= ~other_front + my_front = mi.expand(my_front) & (passable | cands) & ~claimed + claimed |= my_front + mine |= my_front & remaining + remaining &= ~my_front + return mine diff --git a/artifacts/meta/kfort_d6473f1/bots/kfort/planes.py b/artifacts/meta/kfort_d6473f1/bots/kfort/planes.py new file mode 100644 index 0000000..27c280b --- /dev/null +++ b/artifacts/meta/kfort_d6473f1/bots/kfort/planes.py @@ -0,0 +1,99 @@ +"""Bit-sliced score planes (Pantheon figure 14) for gunner placement. + +A plane stack is a list of NUM_PLANES big-int bitmasks; the integer score of +tile n is sum(((planes[i] >> n) & 1) << i). add_const() adds a constant to +every tile of a mask in O(NUM_PLANES) whole-board ops — cost independent of +how many tiles are in the mask. argmax_mask() extracts the best-scoring tiles +in one MSB-down pass. + +Scoring: for each facing, shift every enemy-class mask BACKWARD along the +facing k=1..3 steps with wall clipping; tiles that survive are placements +whose forward ray hits that enemy at range k. Score is discounted with range +(gunners can't shoot over buildings, so adjacency is king). +""" + +NUM_PLANES = 8 +MAX_SCORE = (1 << NUM_PLANES) - 1 + +# enemy class -> base placement value (hit at range 1) +# range discount ~0.9^k like Pantheon +RAY_STEPS = 3 +DISCOUNT = (1.0, 0.9, 0.81) + + +def add_const(planes, c, mask): + """Add constant c to every tile in mask (ripple-carry over planes).""" + if not mask or c <= 0: + return + i = 0 + while c and i < NUM_PLANES: + if c & 1: + carry = planes[i] & mask + planes[i] ^= mask + j = i + 1 + while carry and j < NUM_PLANES: + new_carry = planes[j] & carry + planes[j] ^= carry + carry = new_carry + j += 1 + c >>= 1 + i += 1 + + +def argmax_mask(planes, cands): + """(best_score, mask_of_best_tiles) among cands; (0, 0) if none.""" + if not cands: + return 0, 0 + best = 0 + cur = cands + for i in range(NUM_PLANES - 1, -1, -1): + t = cur & planes[i] + if t: + cur = t + best |= 1 << i + return best, cur + + +def gunner_planes(mi, class_masks, valid_mask): + """Per-facing plane stacks for gunner placements. + + class_masks: list of (value, enemy_mask). Returns {facing_key: planes} + with facing_key in 'E','W','N','S' — the direction the built gunner faces. + A placement facing E hits targets to its EAST, so target masks are shifted + WEST (backward) onto placement tiles. + """ + shifts_back = { + 'E': mi.west, 'W': mi.east, 'N': mi.south, 'S': mi.north, + } + out = {} + nw = ~mi.walls + for fk, back in shifts_back.items(): + planes = [0] * NUM_PLANES + for value, emask in class_masks: + if not emask: + continue + s = emask + for k in range(RAY_STEPS): + s = back(s) & nw + if not s: + break + v = int(value * DISCOUNT[k]) + if v <= 0: + continue + add_const(planes, v, s & valid_mask) + out[fk] = planes + return out + + +def best_placement(mi, class_masks, valid_mask): + """(score, x, y, facing_key) of the best gunner placement, or None.""" + stacks = gunner_planes(mi, class_masks, valid_mask) + best = None + for fk, planes in stacks.items(): + score, mask = argmax_mask(planes, valid_mask) + if score > 0 and mask: + lsb = mask & -mask + x, y = mi.xy(lsb) + if best is None or score > best[0]: + best = (score, x, y, fk) + return best diff --git a/artifacts/meta/kfort_d6473f1/bots/kfort/symmetry.py b/artifacts/meta/kfort_d6473f1/bots/kfort/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/artifacts/meta/kfort_d6473f1/bots/kfort/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bot.zip b/bot.zip index 9d1853fe0ddb537601f3c7f6d5fd97f54c688664..a8e302fe88c486d34e48e0914b4461d8738f3949 100644 GIT binary patch literal 15510 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 bxC85d3v&w6VBr682>Q>i{>@3;|FQaClrfV- literal 28971 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 diff --git a/conveyor_audit.py b/conveyor_audit.py new file mode 100644 index 0000000..2073af2 --- /dev/null +++ b/conveyor_audit.py @@ -0,0 +1,159 @@ +"""Audit economic conveyor topology in a local .replay26. + +Reports living conveyors, facing-proven core connections, dead ends, and +harvesters with a connected output chain at selected rounds. Direction values +1/3/5/7 are the replay protocol's N/E/S/W enum values. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from replay_stats import PAYLOAD_TYPE, pos_of, walk + + +DIRECTION_DELTA = { + 1: (0, -1), + 3: (1, 0), + 5: (0, 1), + 7: (-1, 0), +} + + +def replay_state(path: Path, snapshot_rounds): + top = walk(path.read_bytes()) + map_message = walk(top[(1, "m")][0]) or {} + cores = {} + for raw_core in map_message.get((4, "m"), []): + core = walk(raw_core) or {} + team = core.get((2, "v"), [0])[0] + cores[team] = pos_of(core[(3, "m")][0]) + + entities = {} + snapshots = {} + turns = top[(3, "m")] + wanted = set(snapshot_rounds) | {len(turns) - 1} + for round_number, raw_turn in enumerate(turns): + turn = walk(raw_turn) or {} + for raw_event in turn.get((1, "m"), []): + event = walk(raw_event) or {} + if (1, "m") in event: + outer = walk(event[(1, "m")][0]) or {} + created = ( + walk(outer[(1, "m")][0]) + if (1, "m") in outer else outer + ) or {} + entity_id = created.get((1, "v"), [None])[0] + entity_type = None + facing = None + for field, name in PAYLOAD_TYPE.items(): + if (field, "m") not in created: + continue + entity_type = name + if name == "conveyor": + payload = walk(created[(field, "m")][0]) or {} + facing = payload.get((1, "v"), [None])[0] + entities[entity_id] = { + "team": created.get((2, "v"), [0])[0], + "type": entity_type, + "position": pos_of(created[(3, "m")][0]), + "facing": facing, + "born": round_number, + "alive": True, + } + elif (3, "m") in event or (13, "m") in event: + field = (3, "m") if (3, "m") in event else (13, "m") + death = walk(event[field][0]) or {} + entity_id = death.get((1, "v"), [None])[0] + if entity_id in entities: + entities[entity_id]["alive"] = False + if round_number in wanted: + snapshots[round_number] = { + entity_id: record.copy() + for entity_id, record in entities.items() + } + return cores, len(turns), snapshots + + +def topology(core, entities, team): + footprint = { + (core[0] + dx, core[1] + dy) + for dx in (0, 1) for dy in (0, 1) + } + conveyors = { + record["position"]: record + for record in entities.values() + if record["alive"] and record["team"] == team + and record["type"] == "conveyor" + } + harvesters = { + record["position"] + for record in entities.values() + if record["alive"] and record["team"] == team + and record["type"] == "harvester" + } + connected = set() + changed = True + while changed: + changed = False + for position, record in conveyors.items(): + if position in connected or record["facing"] not in DIRECTION_DELTA: + continue + dx, dy = DIRECTION_DELTA[record["facing"]] + output = (position[0] + dx, position[1] + dy) + if output in footprint or output in connected: + connected.add(position) + changed = True + + dead_ends = set() + for position, record in conveyors.items(): + delta = DIRECTION_DELTA.get(record["facing"]) + if delta is None: + dead_ends.add(position) + continue + output = (position[0] + delta[0], position[1] + delta[1]) + if output not in footprint and output not in conveyors: + dead_ends.add(position) + + served = set() + for harvester in harvesters: + for position, record in conveyors.items(): + if abs(position[0] - harvester[0]) + abs(position[1] - harvester[1]) != 1: + continue + delta = DIRECTION_DELTA.get(record["facing"]) + if delta is None: + continue + output = (position[0] + delta[0], position[1] + delta[1]) + if output != harvester and position in connected: + served.add(harvester) + break + return { + "conveyors": len(conveyors), + "connected": len(connected), + "disconnected": len(conveyors) - len(connected), + "dead_ends": len(dead_ends), + "harvesters": len(harvesters), + "served": len(served), + "orphan_positions": sorted(harvesters - served), + "post40_conveyors": sum( + record["born"] >= 40 for record in conveyors.values()), + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("replay", type=Path) + parser.add_argument("--rounds", default="40,80,100,500,999") + args = parser.parse_args() + rounds = [int(value) for value in args.rounds.split(",") if value] + cores, turn_count, snapshots = replay_state(args.replay, rounds) + print(f"{args.replay} ({turn_count} rounds)") + for round_number in sorted(snapshots): + print(f"round {round_number}") + for team in sorted(cores): + print(f" {'AB'[team]} {topology(cores[team], snapshots[round_number], team)}") + + +if __name__ == "__main__": + main() diff --git a/generalist-v2.zip b/generalist-v2.zip new file mode 100644 index 0000000000000000000000000000000000000000..f5db403d7238e2a3f9cee0ac0bfe2e50577d6679 GIT binary patch literal 17713 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 Tl?^1x0)%gYv>tHapMe1YOuQ@P literal 0 HcmV?d00001 diff --git a/generalist_baseline.json b/generalist_baseline.json new file mode 100644 index 0000000..3a27bfd --- /dev/null +++ b/generalist_baseline.json @@ -0,0 +1,80 @@ +{ + "engine": "fcode, version 2.3.1.dev27", + "bot": "core-sniper-v1", + "seeds": 16, + "both_sides": true, + "cells": [ + { + "opponent": "champion", + "map": "string", + "games": 32, + "wins": 19, + "core_kills": 0, + "core_kills_against": 3, + "turn_1000_games": 29, + "mean_collected_differential": 1386.2, + "mean_candidate_units": 22.3, + "mean_opponent_units": 20.2 + }, + { + "opponent": "champion", + "map": "bridge", + "games": 32, + "wins": 12, + "core_kills": 4, + "core_kills_against": 10, + "turn_1000_games": 18, + "mean_collected_differential": -1038.4, + "mean_candidate_units": 17.5, + "mean_opponent_units": 23.0 + }, + { + "opponent": "lastpop2", + "map": "string", + "games": 32, + "wins": 27, + "core_kills": 22, + "core_kills_against": 0, + "turn_1000_games": 10, + "mean_collected_differential": 570.3, + "mean_candidate_units": 24.4, + "mean_opponent_units": 4.1 + }, + { + "opponent": "lastpop2", + "map": "bridge", + "games": 32, + "wins": 23, + "core_kills": 21, + "core_kills_against": 3, + "turn_1000_games": 8, + "mean_collected_differential": 590.3, + "mean_candidate_units": 20.9, + "mean_opponent_units": 4.1 + }, + { + "opponent": "OogwayOld", + "map": "string", + "games": 32, + "wins": 11, + "core_kills": 3, + "core_kills_against": 1, + "turn_1000_games": 28, + "mean_collected_differential": -2059.7, + "mean_candidate_units": 21.0, + "mean_opponent_units": 23.5 + }, + { + "opponent": "OogwayOld", + "map": "bridge", + "games": 32, + "wins": 17, + "core_kills": 15, + "core_kills_against": 8, + "turn_1000_games": 9, + "mean_collected_differential": -223.4, + "mean_candidate_units": 14.5, + "mean_opponent_units": 13.2 + } + ] +} diff --git a/generalist_v2_results.json b/generalist_v2_results.json new file mode 100644 index 0000000..3473b07 --- /dev/null +++ b/generalist_v2_results.json @@ -0,0 +1,64 @@ +{ + "engine": "fcode, version 2.3.1.dev27 (historical full gates)", + "validation_status": "full dev29 regate required before upload or promotion", + "dev29_smoke": { + "engine": "fcode, version 2.3.2.dev29", + "map_pool": 21, + "updated_map": "jackpot", + "opponent": "core-sniper-v1", + "games": 8, + "wins": 4, + "losses": 4, + "core_kills": 4, + "core_kills_against": 2 + }, + "bot": "generalist-v2", + "source": "exp_generalist_stack", + "components": { + "local_responder": "accepted", + "surgical_counter_battery": "accepted", + "spawn_discipline": "rejected_mechanism_gate", + "adaptive_stall_pause": "rejected_parent_gate" + }, + "full_gates": [ + { + "opponent": "core-sniper-v1", + "games": 168, + "wins": 102, + "losses": 66, + "core_kills": 94, + "core_kills_against": 58 + }, + { + "opponent": "champion", + "games": 168, + "wins": 137, + "losses": 31, + "core_kills": 124, + "core_kills_against": 21 + }, + { + "opponent": "lastpop2", + "games": 168, + "wins": 151, + "losses": 17, + "core_kills": 145, + "core_kills_against": 15 + }, + { + "opponent": "OogwayOld", + "games": 168, + "wins": 148, + "losses": 20, + "core_kills": 147, + "core_kills_against": 12 + } + ], + "sha256": { + "main.py": "3A0E83FA5AD9FA4C2D1736733BC3A8FA862CC5F1821A9C4FF9AB3A291E04DECF", + "initialSpawning.py": "E6D48213A505729ED98BBBC1B55623484BC83B202B2E78D4FA9E929686D40BB6", + "mapPathfinding.py": "971A102A26E8792E6B0FED6FE8F7710641FD1D03D299322F97423A43342AE4A6", + "symmetry.py": "8C4AB5843AB90F8C0907E261E2049B75588BC88BFB8CEEA2CC236E3CD10CD9A1", + "generalist-v2.zip": "64C26B303B1D9AE531A5EC9E69EBB92DF364E13C6ABF15E72A9C218D8AF83F3C" + } +} diff --git a/initialSpawning.py b/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/main.py b/main.py index 5fdbc93..7f6db54 100644 --- a/main.py +++ b/main.py @@ -1,1443 +1,702 @@ -"""Khaos rebuild — memoryless state-scoring bot (Pantheon architecture). +"""Starter bot - a simple example to demonstrate usage of the Controller API. -Every builder, every turn: score seven candidate states in descending ceiling -order with early exit, run the single best. No mode flags, no multi-turn -commitments — targets are re-derived each turn from the bitmask map layer, -deconflicted through a Voronoi claim partition, with TTL caches for targets -that recently failed. - -States (ceiling): attack 9 · heal 8 · route 7.75 · secure 7.5 · harvest 4 · -disrupt 2 · explore 1. +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. """ import random from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker -import mapanalysis -import mapinfo as mapinfo_mod -import path -import planes -import symmetry - +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] -DELTA_TO_DIR = { - (0, -1): Direction.NORTH, (0, 1): Direction.SOUTH, - (1, 0): Direction.EAST, (-1, 0): Direction.WEST, -} - -GLOBAL_AMMO_BUFFER = 20 -MAX_OPEN_BUILDERS = 5 -TTL_ROUNDS = 100 -CPU_SOFT_STOP = 6500 # stop scoring lower states past this many us - - -def manhattan(a, b): - return abs(a.x - b.x) + abs(a.y - b.y) +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) class Player: def __init__(self): - self.mi = None - self.myNum = -1 + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() self.numSpawned = 0 - self.teamCore = None - self.teamCoreTiles = None - self.enemyCore = None - self.symTracker = None - self.fullmap = None - self.fullmapVer = -1 - self.routeField = None - self.routeVer = -1 - self._connected = set() # own conveyors whose chain reaches the core - self._homeDisc = None # cached home-guard disc for builder #1 - self.brokenBarriers = [] # own barriers we broke while walking - self._plantCache = None # score-plane best placement cache - self.enemyField = None # dist-to-enemy-core field (attack route) - self.enemyFieldVer = -1 - self.ttl = {} # (state, x, y) -> expiry round - self.stuck = 0 - self.lastPos = None - self.exploreTarget = None - # turret / launcher state - self.idleTurns = 0 - self.enemyBotHistory = {} - self.tapWait = 0 # sentinel one-tap coordination hold counter - - # ------------------------------------------------------------------ - def run(self, ct: Controller) -> None: - etype = ct.get_entity_type() - if etype == EntityType.CORE: - self.core(ct) - elif etype == EntityType.BUILDER_BOT: - self.builder(ct) - elif etype == EntityType.SENTINEL: - self.sentinel(ct) - elif etype == EntityType.GUNNER: - self.gunner(ct) - elif etype == EntityType.LAUNCHER: - self.launcher(ct) - - # ------------------------------------------------------------------ - # Core: fan-spawn opening, defensive spawn, ammo buffer - # ------------------------------------------------------------------ - def core(self, ct: Controller) -> None: - import math - pos = ct.get_position() - if self.mi is None: - w, h = ct.get_map_width(), ct.get_map_height() - self.mi = mapinfo_mod.MapInfo(w, h) - mapanalysis.reset_map(w, h) - rnd = ct.get_current_round() - ti = ct.get_global_resources() - # Pantheon spawn policy: baseline 200 until 12 allies, then 400 — - # spawn whenever titanium clears baseline + the builder's own cost. - # (Our old 350/400*scale gates stalled workforce growth exactly - # during establishment.) - baseline = 400 if ct.get_unit_count() >= 12 else 200 - wantSpawn = ( - ti >= baseline + ct.get_builder_bot_cost() - or (rnd >= 30 and ct.get_unit_count() <= 6 - and ti >= ct.get_builder_bot_cost() + 60)) - if wantSpawn: - candidates = [] - for dx in (-1, 0, 1, 2): - for dy in (-1, 0, 1, 2): - if 0 <= dx <= 1 and 0 <= dy <= 1: - continue - p = Position(pos.x + dx, pos.y + dy) - if ct.can_spawn(p): - candidates.append(p) - if candidates: - ccx, ccy = pos.x + 0.5, pos.y + 0.5 - pick = None - # defensive: intruder at the base with no ally on it - enemyPos, enemyDist, allies = None, 1e9, [] - for eid in ct.get_nearby_units(): - ep = ct.get_position(eid) - if ct.get_team(eid) == ct.get_team(): - if ct.get_entity_type(eid) == EntityType.BUILDER_BOT: - allies.append(ep) - continue - d = (ep.x - ccx) ** 2 + (ep.y - ccy) ** 2 - if d < enemyDist: - enemyDist, enemyPos = d, ep - if enemyPos is not None and enemyDist <= 18 and not any( - a.distance_squared(enemyPos) <= 8 for a in allies): - pick = min(candidates, key=lambda p: manhattan(p, enemyPos)) - elif self.numSpawned < 4: - w, h = ct.get_map_width(), ct.get_map_height() - primary = math.atan2(h / 2 - ccy, w / 2 - ccx) - target = primary + (-1.178, -0.393, 0.393, 1.178)[self.numSpawned] - tx, ty = math.cos(target), math.sin(target) - - def align(p): - vx, vy = p.x + 0.5 - ccx, p.y + 0.5 - ccy - norm = math.hypot(vx, vy) or 1.0 - return -(vx * tx + vy * ty) / norm - pick = min(candidates, key=align) - else: - pick = candidates[0] - ct.spawn_builder(pick) + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) self.numSpawned += 1 - ct.write_store(0, self.numSpawned) - # distress: hurt core or enemy building in core vision -> broadcast on - # slot 7 so fanned-out builders converge to heal/counter-battery. - distress = ct.get_hp() < ct.get_max_hp() - if not distress: - for bid in ct.get_nearby_buildings(): - if ct.get_team(bid) != ct.get_team(): - distress = True + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) break - if distress: - ct.write_store(7, rnd) - # ammo buffer (raised while distressed so counter-turrets can fire) - buffer = 40 if distress else GLOBAL_AMMO_BUFFER - cur = ct.get_global_ammo() - if cur < buffer: - spare = ct.get_global_resources() - ct.get_builder_bot_cost() - amount = min(buffer - cur, spare) - if amount > 0 and ct.can_convert_ammo(amount): - ct.convert_ammo(amount) - - # ------------------------------------------------------------------ - # Builder: the memoryless spine - # ------------------------------------------------------------------ - def builder(self, ct: Controller) -> None: - mi = self._setup(ct) - mi.update_vision(ct) - mi.absorb(ct) - self._updateCores(ct) - self._updateSymmetry(ct) - - rnd = ct.get_current_round() - pos = ct.get_position() - if self.lastPos == pos: - self.stuck += 1 - else: - self.stuck = 0 - self.lastPos = pos - - # ttl sweep - if rnd % 16 == 0: - self.ttl = {k: v for k, v in self.ttl.items() if v > rnd} - - # death memory (Ijti fix): ban tiles where our turrets died - if mi.turret_losses: - for (lx, ly) in mi.turret_losses: - self.ttl[('plant', lx, ly)] = rnd + 80 - mi.turret_losses.clear() - self._plantCache = None - - # claims context: my bit vs other friendly bots - my_bit = mi.bit(pos.x, pos.y) - others = mi.own_bots & ~my_bit - - states = ( - (9.0, self._score_attack, self._run_attack), - (8.0, self._score_heal, self._run_heal), - (7.75, self._score_route, self._run_route), - (7.5, self._score_secure, self._run_secure), - (4.0, self._score_harvest, self._run_harvest), - (2.0, self._score_disrupt, self._run_disrupt), - (1.0, self._score_explore, self._run_explore), - ) - best, bestScore, bestData = None, 0.0, None - for ceil, scorefn, runfn in states: - if bestScore >= ceil: - break - if ct.get_cpu_time_elapsed() > CPU_SOFT_STOP and best is not None: - break - s, data = scorefn(ct, mi, my_bit, others) - if s > bestScore: - best, bestScore, bestData = runfn, s, data - if best is not None: - best(ct, mi, bestData) - - # passive extras with leftover action/cpu - self._rebuildBroken(ct, mi) - self._tryChokeBlock(ct, mi) - self._healAdjacent(ct) - mi.share(ct, self.myNum, self.teamCore) - self._advanceAnalysis(ct) - - # ---- setup helpers ---- - def _setup(self, ct): - if self.mi is None: - w, h = ct.get_map_width(), ct.get_map_height() - self.mi = mapinfo_mod.MapInfo(w, h) - mapanalysis.configure_map(w, h) - self.mi.on_block_change = ( - lambda x, y, blocking: mapanalysis.note_structural_tile( - x, y, blocking, w, h)) - if self.myNum == -1: - self.myNum = ct.read_store(0) + 1 - return self.mi - - def _updateCores(self, ct): - if self.teamCore is None: - for bid in ct.get_nearby_buildings(): - if (ct.get_entity_type(bid) == EntityType.CORE - and ct.get_team(bid) == ct.get_team()): - self.teamCore = ct.get_position(bid) - if self.enemyCore is None: - for bid in ct.get_nearby_buildings(): - if (ct.get_entity_type(bid) == EntityType.CORE - and ct.get_team(bid) != ct.get_team()): - self.enemyCore = ct.get_position(bid) - - def _updateSymmetry(self, ct): - mi = self.mi - if self.teamCore is None: - return - if self.symTracker is None: - self.symTracker = symmetry.SymmetryTracker(mi.w, mi.h) - if self.fullmapVer != mi.struct_version or self.fullmap is None: - fm = [[-1] * mi.h for _ in range(mi.w)] - for x in range(mi.w): - col = fm[x] - for y in range(mi.h): - b = 1 << (x + y * mi.w) - if not (mi.seen & b): - continue - if mi.walls & b: - col[y] = 2 - elif mi.blocked & b: - col[y] = 3 - elif mi.ore & b: - col[y] = 1 - else: - col[y] = 0 - self.fullmap = fm - self.fullmapVer = mi.struct_version - self.symTracker.update(self.fullmap, mi.struct_version, self.teamCore) - - def _enemyCoreGuess(self): - if self.enemyCore is not None: - return self.enemyCore - if self.symTracker is not None and self.teamCore is not None: - cx, cy = self.symTracker.enemy_core(self.teamCore) - if 0 <= cx < self.mi.w - 1 and 0 <= cy < self.mi.h - 1: - return Position(cx, cy) - return None - - def _homeMask(self): - """Tiles within squared distance 100 of the core (Pantheon: the very - first builder restricts all its targets to this disc = home guard).""" - mi = self.mi - if self._homeDisc is not None or self.teamCore is None: - return self._homeDisc or 0 - cx, cy = self.teamCore.x, self.teamCore.y - m = 0 - for x in range(max(0, cx - 10), min(mi.w, cx + 11)): - for y in range(max(0, cy - 10), min(mi.h, cy + 11)): - if (x - cx) ** 2 + (y - cy) ** 2 <= 100: - m |= mi.bit(x, y) - self._homeDisc = m - return m - - def _coreMask(self): - mi = self.mi - if self.teamCore is None: - return 0 - m = 0 - for dx in (0, 1): - for dy in (0, 1): - x, y = self.teamCore.x + dx, self.teamCore.y + dy - if 0 <= x < mi.w and 0 <= y < mi.h: - m |= mi.bit(x, y) - return m + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) - # ---- movement ---- - def _moveToward(self, ct, targets_mask, avoid=0): - if ct.get_move_cooldown() != 0: - return - mi = self.mi - pos = ct.get_position() - step = path.next_step(mi, (pos.x, pos.y), targets_mask, avoid) - if step is not None: - d = DELTA_TO_DIR[step] - dest = pos.add(d) - # break-walk own barriers (Pantheon): destroy is free, remember - # the tile so we rebuild it once we've stepped off. - if (self._inBounds(dest) - and mi.own_barriers & mi.bit(dest.x, dest.y) - and not ct.can_move(d) - and ct.is_in_vision(dest) and ct.can_destroy(dest)): - ct.destroy(dest) - mi.own_barriers &= ~mi.bit(dest.x, dest.y) - mi.note_tile(dest.x, dest.y, mapinfo_mod.T_EMPTY) - if len(self.brokenBarriers) < 4: - self.brokenBarriers.append(dest) - if ct.can_move(d): - ct.move(d) - self.stuck = 0 - return - self.stuck += 1 - if self.stuck > 2 + (ct.get_id() % 8): - dirs = [d for d in CARDINALS if ct.can_move(d)] - if dirs: - ct.move(random.choice(dirs)) - - # ------------------------------------------------------------------ - # STATE: attack (9) — plant turrets on enemy buildings / push their core - # ------------------------------------------------------------------ - def _score_attack(self, ct, mi, my_bit, others): - rnd = ct.get_current_round() - ti = ct.get_global_resources() - if ti < ct.get_gunner_cost() + 40: - return 0.0, None - # DEFENSIVE counter-battery: enemy building near OUR core — every - # builder may respond (no role gate), claims still deconflict. - if self.teamCore is not None: - home = 0 - for (x, y) in mi.enemy_buildings: - if self.ttl.get(('atk', x, y), 0) > rnd: - continue - if abs(x - self.teamCore.x) + abs(y - self.teamCore.y) <= 10: - home |= mi.bit(x, y) - if home: - mine = path.claims(mi, my_bit, others, home) - if mine: - return 9.0, ('bld', mine) - if rnd < 30 or self.myNum % 3 != 0 or self.myNum == 1: - return 0.0, None # builder #1 is the home guard, never sieges - guess = self._enemyCoreGuess() - # bit-sliced plane placement (Pantheon): score EVERY legal gunner - # placement board-wide, take the best above threshold. - plant = self._bestPlant(ct, mi) - if plant is not None: - score, px, py, fk = plant - # geographic money gate on the PLACEMENT tile - if self.teamCore is not None and guess is not None: - dHome = abs(px - self.teamCore.x) + abs(py - self.teamCore.y) - dEnemy = abs(px - guess.x) + abs(py - guess.y) - deep = dHome > dEnemy - else: - deep = False - if not deep or ti >= 150: - mine = path.claims(mi, my_bit, others, mi.bit(px, py)) - if mine: - return 9.0, ('plant', (px, py, fk)) - # fallback: walk at remembered enemy buildings (old greedy pathway) - cands = 0 - for (x, y) in mi.enemy_buildings: - key = ('atk', x, y) - if self.ttl.get(key, 0) > rnd: - continue - if self.teamCore is not None and guess is not None: - dHome = abs(x - self.teamCore.x) + abs(y - self.teamCore.y) - dEnemy = abs(x - guess.x) + abs(y - guess.y) - onOurHalf = dHome <= dEnemy - else: - onOurHalf = True - if onOurHalf or ti >= 150: - cands |= mi.bit(x, y) - if cands: - mine = path.claims(mi, my_bit, others, cands) - if mine: - return 8.9, ('bld', mine) - return 0.0, None - if guess is not None: - return 6.0, ('core', guess) - return 0.0, None - - PLANT_VALUES = None # built lazily: EntityType -> placement value - - def _bestPlant(self, ct, mi): - """Best gunner placement from the score planes, cached per - (struct, enemy-set). Threshold 20 = better than a lone barrier.""" - if Player.PLANT_VALUES is None: - Player.PLANT_VALUES = { - EntityType.CORE: 60, EntityType.GUNNER: 40, - EntityType.SENTINEL: 40, EntityType.LAUNCHER: 20, - EntityType.CONVEYOR: 15, EntityType.SPLITTER: 18, - EntityType.HARVESTER: 8, EntityType.BARRIER: 5, - } - key = (mi.struct_version, - tuple(sorted((k, str(v)) - for k, v in mi.enemy_buildings.items()))) - if self._plantCache is not None and self._plantCache[0] == key: - return self._plantCache[1] - by_value = {} - for (x, y), etype in mi.enemy_buildings.items(): - v = Player.PLANT_VALUES.get(etype, 5) - by_value.setdefault(v, [0])[0] = by_value.get(v, [0])[0] \ - | mi.bit(x, y) - class_masks = [(v, m[0]) for v, m in by_value.items()] - valid = (mi.seen & ~mi.walls & ~mi.blocked & ~mi.ore - & ~mi.own_conveyors & ~mi.own_barriers - & ~mi.expand(mi.expand(mi.enemy_bots))) - # death memory (Ijti fix): freshly-fatal tiles are banned - rnd = ct.get_current_round() - for (kind, kx, ky), exp in self.ttl.items(): - if kind == 'plant' and exp > rnd: - valid &= ~mi.bit(kx, ky) - best = None - if class_masks and valid: - # threat penalty (Ijti fix): unthreatened placements first; - # threatened only at 2x value (trade, not feeding tube) - threat = mi.threat() - best = planes.best_placement(mi, class_masks, valid & ~threat) - if best is None or best[0] < 20: - hot = planes.best_placement(mi, class_masks, valid & threat) - if hot is not None and hot[0] >= 40 \ - and (best is None or hot[0] > best[0]): - best = hot - if best is not None and best[0] < 20: - best = None - self._plantCache = (key, best) - return best - - def _enemyFieldCached(self, mi, guess): - key = (mi.struct_version, guess.x, guess.y) - if self.enemyField is not None and self.enemyFieldVer == key: - return self.enemyField - m = 0 - for dx in (0, 1): - for dy in (0, 1): - x, y = guess.x + dx, guess.y + dy - if 0 <= x < mi.w and 0 <= y < mi.h: - m |= mi.bit(x, y) - self.enemyField = path.dist_field(mi, mi.expand(m)) - self.enemyFieldVer = key - return self.enemyField - - def _run_attack(self, ct, mi, data): - kind, payload = data - pos = ct.get_position() - rnd = ct.get_current_round() - if kind == 'core': - # ATTACK ROUTE (Pantheon override): while marching on the enemy - # core, lay a conveyor trail once we're geographically ahead - # (dist-to-them <= 1.5x dist-home). Walkable for us, area denial - # for them; planes convert the head into a gunner on contact. - if (ct.get_action_cooldown() == 0 - and ct.get_global_resources() - >= ct.get_conveyor_cost() + 120): - ef = self._enemyFieldCached(mi, payload) - hf = self._routeFieldCached(ct) - idx = pos.x + pos.y * mi.w - if (ef is not None and hf is not None - and ef[idx] < 4096 and hf[idx] < 4096 - and ef[idx] <= 1.5 * hf[idx]): - best = None - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): - nx, ny = pos.x + dx, pos.y + dy - if not (0 <= nx < mi.w and 0 <= ny < mi.h): - continue - b = mi.bit(nx, ny) - if (mi.walls | mi.blocked | mi.own_conveyors - | mi.own_barriers | mi.ore) & b: - continue - d = ef[nx + ny * mi.w] - if best is None or d < best[0]: - best = (d, nx, ny) - if best is not None and best[0] < ef[idx]: - _, lx, ly = best - link = Position(lx, ly) - # face further downhill toward the enemy core - fb = None - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): - fx, fy = lx + dx, ly + dy - if not (0 <= fx < mi.w and 0 <= fy < mi.h): - continue - fd = ef[fx + fy * mi.w] - if fb is None or fd < fb[0]: - fb = (fd, dx, dy) - if fb is not None: - facing = DELTA_TO_DIR[(fb[1], fb[2])] - if (ct.is_in_vision(link) - and ct.can_build_conveyor(link, facing)): - ct.build_conveyor(link, facing) - mi.own_conveyors |= mi.bit(lx, ly) - mi.own_conv_facing[(lx, ly)] = (fb[1], fb[2]) - m = mi.bit(payload.x, payload.y) - self._moveToward(ct, mi.expand(mi.expand(m))) - return - if kind == 'plant': - px, py, fk = payload - tile = Position(px, py) - facing = {'E': Direction.EAST, 'W': Direction.WEST, - 'N': Direction.NORTH, 'S': Direction.SOUTH}[fk] - tb = mi.bit(px, py) - if pos == tile: - # standing on the placement: step aside - self._moveToward(ct, mi.expand(tb) & mi.passable() & ~tb) - return - if pos.distance_squared(tile) <= 2 \ - and ct.get_action_cooldown() == 0: - if ct.is_in_vision(tile) and ct.can_build_gunner(tile, facing): - ct.build_gunner(tile, facing) - self._plantCache = None - return - self.ttl[('atk', px, py)] = rnd + 20 - self._plantCache = None + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): return - self._moveToward(ct, mi.expand(tb) & mi.passable()) + if (self._is_sieger(ct) and ct.get_current_round() >= SIEGE_START + and (self.siegeCommitted + or ct.get_global_resources() > SIEGE_TITANIUM_FLOOR)): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) return - targets = payload - tb = targets & -targets - tx, ty = mi.xy(tb) - tpos = Position(tx, ty) - dist = manhattan(pos, tpos) - if dist <= 3 and ct.get_action_cooldown() == 0: - # place a turret on an adjacent tile with line to the target - facing = self._cardinalTo(pos, tpos) - placed = False - for d in CARDINALS: - p = pos.add(d) - if not self._inBounds(p) or not ct.is_in_vision(p): - continue - f = self._cardinalTo(p, tpos) - if f == Direction.CENTRE: - continue - if ct.can_build_gunner(p, f): - ct.build_gunner(p, f) - placed = True - break - if (ct.get_global_resources() >= ct.get_sentinel_cost() + 40 - and ct.can_build_sentinel(p, f)): - ct.build_sentinel(p, f) - placed = True - break - if placed: - self.ttl[('atk', tx, ty)] = rnd + 40 + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) return - if facing == Direction.CENTRE: - self.ttl[('atk', tx, ty)] = rnd + TTL_ROUNDS - return - if dist <= 1: - # cannot place and already adjacent: back off this target a while - self.ttl[('atk', tx, ty)] = rnd + 30 - self._moveToward(ct, mi.expand(targets) & mi.passable()) - # ------------------------------------------------------------------ - # STATE: heal (8) — repair damaged own buildings, deter intruders - # ------------------------------------------------------------------ - def _score_heal(self, ct, mi, my_bit, others): - damaged = 0 - # core distress broadcast: converge on the core even when it's out of - # our own vision — 2 medics out-heal a sentinel's grind (8 vs 6/round). - distress = ct.read_store(7) - if (distress > 0 and ct.get_current_round() - distress < 12 - and self.teamCore is not None): - damaged |= self._coreMask() - for bid in ct.get_nearby_buildings(): - if ct.get_team(bid) != ct.get_team(): - continue - if ct.get_hp(bid) + 2 < ct.get_max_hp(bid): - p = ct.get_position(bid) - damaged |= mi.bit(p.x, p.y) - if damaged: - mine = path.claims(mi, my_bit, others, damaged) - if mine: - return 8.0, ('heal', mine) - # Pantheon: own sentinel trading with an enemy sentinel counts as very - # damaged — medics pre-park. Scores 7, BELOW route's 7.75 (the paper's - # exact number; 8.0 parked the workforce and starved the economy). - trading = 0 - for (x, y), tk in mi.own_turrets.items(): - if tk != 'S': - continue - for (ex, ey), ekind in mi.enemy_turrets.items(): - if ekind == 'S' and (x - ex) ** 2 + (y - ey) ** 2 <= 32: - trading |= mi.bit(x, y) - break - if trading: - mine = path.claims(mi, my_bit, others, trading) - if mine: - return 7.0, ('heal', mine) - # Pantheon chase zone: 8 pathing steps around our conveyor network - # (was 2 tiles) — intruders get hunted before they reach the chain. - zone = mi.own_conveyors - passable = mi.passable() - for _ in range(8): - zone = mi.expand(zone) & (passable | zone) - intruders = mi.enemy_bots & zone - if intruders: - mine = path.claims(mi, my_bit, others, intruders) - if mine: - return 7.9, ('chase', mine) - return 0.0, None + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared - def _run_heal(self, ct, mi, data): - kind, mask = data - pos = ct.get_position() - tb = mask & -mask - tx, ty = mi.xy(tb) - tpos = Position(tx, ty) - if kind == 'heal': - if pos.distance_squared(tpos) <= 2: - if ct.can_heal(tpos): - ct.heal(tpos) - return - self._moveToward(ct, mi.expand(tb) & mi.passable()) - return - # chase: stand next to the intruder; the launcher/turret net does the - # rest, and our body denies conveyor-chip tiles - if pos.distance_squared(tpos) <= 2: - if ct.get_action_cooldown() == 0: - for d in CARDINALS: - p = pos.add(d) - if (self._inBounds(p) and ct.is_in_vision(p) - and manhattan(p, tpos) <= 1 - and ct.can_build_launcher(p) - and ct.get_global_resources() - >= ct.get_launcher_cost() + 60): - ct.build_launcher(p) - return - return - # Pantheon counter-launcher fallback: chase can't PATH to the - # intruder (their launcher zone / barriers) -> zone them back - # instead of giving up. - step = path.next_step(mi, (pos.x, pos.y), - mi.expand(tb) & mi.passable()) - if step is None: - if (manhattan(pos, tpos) <= 3 and ct.get_action_cooldown() == 0 - and ct.get_global_resources() - >= ct.get_launcher_cost() + 60): - for d in CARDINALS: - p = pos.add(d) - if (self._inBounds(p) and ct.is_in_vision(p) - and manhattan(p, tpos) <= 2 - and ct.can_build_launcher(p)): - ct.build_launcher(p) - return - self._moveToward(ct, mi.expand(mi.expand(tb)) & mi.passable()) - return - self._moveToward(ct, mi.expand(tb) & mi.passable()) - - # ------------------------------------------------------------------ - # STATE: route (7.75) — stateless chain building down the core gradient - # ------------------------------------------------------------------ - def _connectedConveyors(self, mi): - """Own conveyors whose chain (by facing) reaches the core — legal - merge targets for new routes (Pantheon: routes may end on existing - conveyors). Fixpoint over remembered facings.""" - coreMask = self._coreMask() - core_xy = set() - m = coreMask - while m: - lsb = m & -m - core_xy.add(mi.xy(lsb)) - m ^= lsb - connected = set() - changed = True - while changed: - changed = False - for (x, y), (dx, dy) in mi.own_conv_facing.items(): - if (x, y) in connected: - continue - out = (x + dx, y + dy) - if out in core_xy or out in connected: - connected.add((x, y)) - changed = True - return connected - - def _routeFieldCached(self, ct): - mi = self.mi - key = (mi.struct_version, len(mi.own_conv_facing), - tuple(sorted(mi.enemy_turrets))) - if self.routeField is not None and self.routeVer == key: - return self.routeField - coreMask = self._coreMask() - if not coreMask: + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: return None - # Pantheon merge targets: the core PLUS every conveyor already - # connected to it — new chains join existing trunks instead of each - # harvester paying for a private line the whole way home. LOADED - # trunk tiles are excluded (P6): a full conveyor can't accept a new - # stack, so merging there jams the junction (their +4 penalty, made - # binary by dev23's hold-1-stack rule). - self._connected = self._connectedConveyors(mi) - targets = coreMask - for (x, y) in self._connected: - targets |= mi.bit(x, y) - targets &= ~mi.own_loaded | coreMask - # Pantheon universal AVOID mask (p7): tiles covered by enemy turrets - # are HARD-excluded from conveyor route planning, not soft-costed — - # a link placed under a known gun is titanium fed to the enemy - # (Landers vault r139: we rebuilt one threatened link 7 times). - # Existing own conveyors stay routable so trunks aren't orphaned. - avoid = mi.threat() & ~mi.own_conveyors & ~coreMask - self.routeField = path.dist_field(mi, targets, avoid_mask=avoid) - self.routeVer = key - return self.routeField + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) - @staticmethod - def _chainRoot(mi, hx, hy): - """Adjacent own conveyor whose output is NOT the harvester — a real - chain root. Inward-facing guards don't count (Khaos guard mask).""" - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): - cx, cy = hx + dx, hy + dy - f = mi.own_conv_facing.get((cx, cy)) - if f is None: - continue - if (cx + f[0], cy + f[1]) != (hx, hy): - return (cx, cy) - return None + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) - def _downhillLink(self, mi, field, x, y): - """Best empty neighbour to lay the next conveyor on, plus the facing - that conveyor should have (toward ITS best downhill neighbour).""" + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Walk to an exact gunner firing seat and rebuild it indefinitely.""" + target = self._enemy_core_target() + if target is None: + return + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] best = None - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): - nx, ny = x + dx, y + dy - if not (0 <= nx < mi.w and 0 <= ny < mi.h): - continue - b = mi.bit(nx, ny) - if (mi.walls | mi.blocked | mi.own_conveyors) & b: + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: continue - d = field[nx + ny * mi.w] - if best is None or d < best[0]: - best = (d, nx, ny) - if best is None or best[0] >= 4096: - return None, None - _, lx, ly = best - fbest = None - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): - fx, fy = lx + dx, ly + dy - if not (0 <= fx < mi.w and 0 <= fy < mi.h): + if ct.get_tile_building_id(seat) is not None: continue - fd = field[fx + fy * mi.w] - if fbest is None or fd < fbest[0]: - fbest = (fd, dx, dy) - if fbest is None: - return None, None - return Position(lx, ly), DELTA_TO_DIR[(fbest[1], fbest[2])] - - def _orphanHarvesters(self, ct, mi): - rnd = ct.get_current_round() - orphans = 0 - for (x, y) in mi.own_harvesters: - key = ('route', x, y) - if self.ttl.get(key, 0) > rnd: - continue - if self._chainRoot(mi, x, y) is None: - orphans |= mi.bit(x, y) - return orphans - - def _deadEnds(self, ct, mi): - """Own conveyors whose output tile has no continuation (Khaos - dead-end route targets). Guards (facing into a harvester) excluded.""" - rnd = ct.get_current_round() - coreMask = self._coreMask() - ends = 0 - for (x, y), (dx, dy) in mi.own_conv_facing.items(): - if self.ttl.get(('route', x, y), 0) > rnd: - continue - ox, oy = x + dx, y + dy - if (ox, oy) in mi.own_harvesters: - continue # inward guard, carries nothing - if not (0 <= ox < mi.w and 0 <= oy < mi.h): - ends |= mi.bit(x, y) - continue - ob = mi.bit(ox, oy) - if ob & coreMask: - continue # delivers into the core: chain complete - if mi.own_conveyors & ob: - continue # continues into another conveyor/splitter - ends |= mi.bit(x, y) - return ends - - def _score_route(self, ct, mi, my_bit, others): - if self.teamCore is None: - return 0.0, None - cands = self._orphanHarvesters(ct, mi) | self._deadEnds(ct, mi) - if not cands: - return 0.0, None - mine = path.claims(mi, my_bit, others, cands) - if not mine: - return 0.0, None - near_enemy = mi.expand(mi.expand(mi.enemy_bots)) - important = mine & near_enemy - return (7.75 if important else 5.0), mine - - def _run_route(self, ct, mi, mask): - field = self._routeFieldCached(ct) - rnd = ct.get_current_round() - hb = mask & -mask - hx, hy = mi.xy(hb) - if field is None: - return - coreMask = self._coreMask() - link = facing = None - if (hx, hy) in mi.own_conv_facing: - root = (hx, hy) # dead-end conveyor target: walk from it - else: - root = self._chainRoot(mi, hx, hy) - # P6 cost gate (Pantheon cost(d)): before STARTING a fresh chain, - # check we can afford ~the whole line. 0.65 discount — income - # keeps arriving while we build. - d = field[hx + hy * mi.w] - if d < 4096: - est = 3 * d * ct.get_scale_percent() / 100 * 0.65 - if ct.get_global_resources() < est: - self.ttl[('route', hx, hy)] = rnd + 30 - return - if root is None: - link, facing = self._downhillLink(mi, field, hx, hy) - else: - # follow the actual chain by facings until it breaks / reaches core - cur = root - visited = set() - for _ in range(mi.w + mi.h): - if cur in visited: - self.ttl[('route', hx, hy)] = rnd + 30 - return - visited.add(cur) - f = mi.own_conv_facing.get(cur) - if f is None: - self.ttl[('route', hx, hy)] = rnd + 15 - return - ox, oy = cur[0] + f[0], cur[1] + f[1] - if not (0 <= ox < mi.w and 0 <= oy < mi.h): - link, facing = self._downhillLink(mi, field, - cur[0], cur[1]) + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: break - ob = mi.bit(ox, oy) - if ob & coreMask or (ox, oy) in self._connected: - self.ttl[('route', hx, hy)] = rnd + 30 # chain complete - return - if (ox, oy) in mi.own_conv_facing: - cur = (ox, oy) + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): continue - if mi.own_conveyors & ob: - # splitter / facing not yet observed: assume connected - self.ttl[('route', hx, hy)] = rnd + 15 - return - if (mi.walls | mi.blocked) & ob: - link, facing = self._downhillLink(mi, field, - cur[0], cur[1]) - break - # empty output tile: that's the missing link - link = Position(ox, oy) - fbest = None - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): - fx, fy = ox + dx, oy + dy - if not (0 <= fx < mi.w and 0 <= fy < mi.h): + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: continue - fd = field[fx + fy * mi.w] - if fbest is None or fd < fbest[0]: - fbest = (fd, dx, dy) - if fbest is None: - return - facing = DELTA_TO_DIR[(fbest[1], fbest[2])] - break - if link is None: - # Pantheon: an unroutable dead-end near enemies gets CAPPED with - # a barrier on its output so they can't exploit/extend it - f = mi.own_conv_facing.get((hx, hy)) - if f is not None and mi.enemy_bots: - near = mi.expand(mi.expand(mi.expand(mi.expand( - mi.bit(hx, hy))))) - if near & mi.enemy_bots and ct.get_action_cooldown() == 0: - ox, oy = hx + f[0], hy + f[1] - if 0 <= ox < mi.w and 0 <= oy < mi.h: - cap = Position(ox, oy) - pos0 = ct.get_position() - if (pos0.distance_squared(cap) <= 2 - and ct.is_in_vision(cap) - and ct.can_build_barrier(cap)): - ct.build_barrier(cap) - mi.own_barriers |= mi.bit(ox, oy) - mi.note_tile(ox, oy, mapinfo_mod.T_BLOCK) - self.ttl[('route', hx, hy)] = rnd + 20 - return - pos = ct.get_position() - lb = mi.bit(link.x, link.y) - if pos == link: - # standing on the link tile: step aside so we can build it - self._moveToward(ct, mi.expand(lb) & mi.passable() & ~lb) - return - if pos.distance_squared(link) <= 2 and ct.get_action_cooldown() == 0: - if ct.is_in_vision(link) and ct.can_build_conveyor(link, facing): - ct.build_conveyor(link, facing) - mi.own_conveyors |= lb - mi.own_conv_facing[(link.x, link.y)] = ( - mapinfo_mod.DIR_DELTA[facing]) - return - self.ttl[('route', hx, hy)] = rnd + 10 + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + + if best is None: + self.mapPf.moveTo(ct, target) return - self._moveToward(ct, mi.expand(lb) & mi.passable()) - # ------------------------------------------------------------------ - # STATE: secure (7.5) — guard-conveyor exposed harvesters near enemies - # ------------------------------------------------------------------ - def _score_secure(self, ct, mi, my_bit, others): - if not mi.enemy_buildings and not mi.enemy_bots: - return 0.0, None - danger = 0 - for (x, y) in mi.enemy_buildings: - danger |= mi.bit(x, y) - danger = mi.expand(mi.expand(mi.expand(danger))) | \ - mi.expand(mi.expand(mi.enemy_bots)) - exposed = 0 - for (x, y) in mi.own_harvesters: - b = mi.bit(x, y) - if not (danger & mi.expand(b)): - continue - open_sides = mi.expand(b) & ~b & mi.passable() & ~mi.own_conveyors - if open_sides: - exposed |= open_sides - # SECURE THE CORE (Pantheon secure-state on the ultimate exposed - # building): a conveyor ring denies point-blank turret plants AND - # absorbs distant gunner rays (ray stops at first targetable - # building — 20hp conveyor tanks it, not the 500hp core). Only under - # threat near home, so peaceful econ games skip the +scale tax. - # (lastpopperian strait r136: two gunners planted at our doorstep, - # 20dmg/round vs 8/round of medic healing.) - if self.teamCore is not None: - coreMask = self._coreMask() - ring = mi.expand(coreMask) & ~coreMask # cardinal shell-1 only: - # gunner rays are cardinal, and distant rays die in shell 1 - openRing = ring & mi.passable() & ~mi.own_conveyors \ - & ~mi.own_barriers & ~mi.ore - homeThreat = danger & self._homeMask() - if openRing and homeThreat: - exposed |= openRing - if not exposed: - return 0.0, None - mine = path.claims(mi, my_bit, others, exposed) - if not mine: - return 0.0, None - return 7.5, mine - - def _run_secure(self, ct, mi, mask): - pos = ct.get_position() - tb = mask & -mask - tx, ty = mi.xy(tb) - tpos = Position(tx, ty) - if pos.distance_squared(tpos) <= 2 and ct.get_action_cooldown() == 0: - if ct.is_in_vision(tpos): - # find the harvester this side belongs to - harv = None + _, seat, stand, facing = best + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None for d in CARDINALS: - hp = tpos.add(d) - if (self._inBounds(hp) - and (hp.x, hp.y) in mi.own_harvesters): - harv = (hp, d) + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n break - if harv is None: - # core-ring tile: face INTO the core (denies the plant, - # absorbs rays, and delivers titanium) - coreMask = self._coreMask() + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) for d in CARDINALS: - cp = tpos.add(d) - if (self._inBounds(cp) - and coreMask & mi.bit(cp.x, cp.y)): - if ct.can_build_conveyor(tpos, d): - ct.build_conveyor(tpos, d) - mi.own_conveyors |= mi.bit(tpos.x, tpos.y) - mi.own_conv_facing[(tpos.x, tpos.y)] = ( - mapinfo_mod.DIR_DELTA[d]) - return - return - hp, inward = harv - # Khaos: if this side is the harvester's downhill side, face - # OUTWARD so the guard doubles as the chain root; otherwise - # face INTO the harvester so it never carries resources. - facing = inward - field = self._routeFieldCached(ct) - if field is not None: - dlink, dfacing = self._downhillLink(mi, field, hp.x, hp.y) - if dlink is not None and dlink == tpos: - facing = dfacing - if ct.can_build_conveyor(tpos, facing): - ct.build_conveyor(tpos, facing) - mi.own_conveyors |= mi.bit(tpos.x, tpos.y) - mi.own_conv_facing[(tpos.x, tpos.y)] = ( - mapinfo_mod.DIR_DELTA[facing]) - return - return - self._moveToward(ct, mi.expand(tb) & mi.passable()) - - # ------------------------------------------------------------------ - # STATE: harvest (4) - # ------------------------------------------------------------------ - def _score_harvest(self, ct, mi, my_bit, others): - rnd = ct.get_current_round() - # exclude only ore in the CLOSE disc of an enemy turret. The full - # facing-agnostic band mask vetoed most of the ore field on turret-y - # maps (quarry: 5-10k mined vs botv2's 18k) — band-edge mining is - # viable with heal upkeep (v29 lesson). - hot = 0 - for (x, y) in mi.enemy_turrets: - hot |= mi.bit(x, y) - hot = mi.expand(mi.expand(hot)) - cands = mi.ore & mi.seen & ~mi.blocked & ~mi.walls & ~hot - pruned = 0 - for lsb in mi.iter_bits(cands): - x, y = mi.xy(lsb) - if (x, y) in mi.own_harvesters: - continue - if self.ttl.get(('harv', x, y), 0) > rnd: - continue - pruned |= lsb - if self.myNum == 1: - pruned &= self._homeMask() # home guard mines home ore only - if not pruned: - return 0.0, None - mine = path.claims(mi, my_bit, others, pruned) - if not mine: - return 0.0, None - if ct.get_global_resources() < ct.get_harvester_cost(): - return 0.5, mine - return 4.0, mine - - def _run_harvest(self, ct, mi, mask): - pos = ct.get_position() - target = path.closest(mi, mi.bit(pos.x, pos.y), mask) - if not target: - target = mask & -mask - tx, ty = mi.xy(target) - tpos = Position(tx, ty) - rnd = ct.get_current_round() - if pos == tpos: - # standing on the ore: sidestep so we can build it - self._moveToward(ct, mi.expand(target) & mi.passable() & ~target) + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: return - if pos.distance_squared(tpos) <= 2 and ct.get_action_cooldown() == 0: - if ct.is_in_vision(tpos) and ct.can_build_harvester(tpos): - ct.build_harvester(tpos) - mi.own_harvesters[(tx, ty)] = True + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: return - self.ttl[('harv', tx, ty)] = rnd + 25 - return - self._moveToward(ct, mi.expand(target) & mi.passable()) - - # ------------------------------------------------------------------ - # STATE: disrupt (2) — barrier enemy-half ore when rich - # ------------------------------------------------------------------ - def _score_disrupt(self, ct, mi, my_bit, others): - if self.myNum == 1: - return 0.0, None # home guard stays home - if ct.get_global_resources() < 5 * ct.get_harvester_cost(): - return 0.0, None - guess = self._enemyCoreGuess() - if guess is None or self.teamCore is None: - return 0.0, None - rnd = ct.get_current_round() - cands = 0 - for lsb in mi.iter_bits(mi.ore & mi.seen & ~mi.blocked - & ~mi.threat()): - x, y = mi.xy(lsb) - p = Position(x, y) - if manhattan(p, guess) < manhattan(p, self.teamCore) - 2: - if self.ttl.get(('dis', x, y), 0) <= rnd: - cands |= lsb - if not cands: - return 0.0, None - mine = path.claims(mi, my_bit, others, cands) - if not mine: - return 0.0, None - return 2.0, mine - - def _run_disrupt(self, ct, mi, mask): - pos = ct.get_position() - tb = mask & -mask - tx, ty = mi.xy(tb) - tpos = Position(tx, ty) - rnd = ct.get_current_round() - if pos.distance_squared(tpos) <= 2 and pos != tpos \ - and ct.get_action_cooldown() == 0: - if ct.is_in_vision(tpos) and ct.can_build_barrier(tpos): - ct.build_barrier(tpos) - self.ttl[('dis', tx, ty)] = rnd + 1000 - return - self.ttl[('dis', tx, ty)] = rnd + 50 - return - if pos == tpos: - self._moveToward(ct, mi.expand(tb) & mi.passable() & ~tb) - return - self._moveToward(ct, mi.expand(tb) & mi.passable()) - - # ------------------------------------------------------------------ - # STATE: explore (1) - # ------------------------------------------------------------------ - def _score_explore(self, ct, mi, my_bit, others): - return 1.0, None - - def _run_explore(self, ct, mi, _): - pos = ct.get_position() - if (self.exploreTarget is not None - and manhattan(pos, self.exploreTarget) <= 2): - self.exploreTarget = None - if self.exploreTarget is None or self.stuck > 4: - frontier = mi.frontier() & mi.passable() - if self.myNum == 1: - frontier &= self._homeMask() # guard patrols home only - picked = None - if frontier: - bits = list(mi.iter_bits(frontier)) - picked = random.choice(bits[:64]) - if picked: - x, y = mi.xy(picked) - self.exploreTarget = Position(x, y) - elif self.myNum == 1 and self.teamCore is not None: - self.exploreTarget = Position( - max(0, min(mi.w - 1, - self.teamCore.x + random.randint(-4, 4))), - max(0, min(mi.h - 1, - self.teamCore.y + random.randint(-4, 4)))) - else: - self.exploreTarget = Position(random.randrange(mi.w), - random.randrange(mi.h)) - t = self.exploreTarget - self._moveToward(ct, mi.bit(t.x, t.y)) - - # ------------------------------------------------------------------ - # passive extras - # ------------------------------------------------------------------ - def _rebuildBroken(self, ct, mi): - """Replace own barriers we destroyed to walk through (Pantheon).""" - if not self.brokenBarriers or ct.get_action_cooldown() != 0: - return - pos = ct.get_position() - for p in list(self.brokenBarriers): - if pos == p: - continue # still standing on it; rebuild after stepping off - if pos.distance_squared(p) > 2: - self.brokenBarriers.remove(p) # walked away: let it go - continue - if ct.is_in_vision(p) and ct.can_build_barrier(p): - ct.build_barrier(p) - mi.own_barriers |= mi.bit(p.x, p.y) - mi.note_tile(p.x, p.y, mapinfo_mod.T_BLOCK) - self.brokenBarriers.remove(p) - return - - def _tryChokeBlock(self, ct, mi): - if ct.get_action_cooldown() != 0 or not mapanalysis.chokes \ - or self.teamCore is None: - return - pos = ct.get_position() - enemyRef = self._enemyCoreGuess() - ti = ct.get_global_resources() - for choke in mapanalysis.chokes: - tile, clearance = choke[0], choke[1] - p = Position(tile[0], tile[1]) - if pos.distance_squared(p) > 2: - continue - if (enemyRef is not None - and manhattan(p, self.teamCore) >= manhattan(p, enemyRef)): + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: continue - if not self._inBounds(p) or not ct.is_in_vision(p): + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: continue - if ct.get_tile_building_id(p) is not None: + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked continue - if ct.get_tile_env(p) == Environment.ORE_TITANIUM: + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: continue - if clearance <= 1.2: - if ti >= ct.get_barrier_cost() + 30 and ct.can_build_barrier(p): - ct.build_barrier(p) - return - elif ti >= 100 and ct.can_build_launcher(p): - ct.build_launcher(p) + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) return - - def _healAdjacent(self, ct): - if ct.get_action_cooldown() != 0: - return - pos = ct.get_position() - if ct.get_hp() < ct.get_max_hp() and ct.can_heal(pos): - ct.heal(pos) - return - # Pantheon 6-tier heal priority: (very damaged?, type tier, damage) - TIER = {EntityType.CORE: 6, EntityType.GUNNER: 5, - EntityType.SENTINEL: 5, EntityType.LAUNCHER: 5, - EntityType.HARVESTER: 4, EntityType.CONVEYOR: 3, - EntityType.SPLITTER: 2, EntityType.BARRIER: 2} - best = None - for bid in ct.get_nearby_buildings(2): - if ct.get_team(bid) != ct.get_team(): - continue - dmg = ct.get_max_hp(bid) - ct.get_hp(bid) - if dmg >= 4: - p = ct.get_position(bid) - key = (1 if dmg >= 12 else 0, - TIER.get(ct.get_entity_type(bid), 1), dmg) - if best is None or key > best[0]: - best = (key, p) - if best is not None and ct.can_heal(best[1]): - ct.heal(best[1]) - - def _advanceAnalysis(self, ct): - job = mapanalysis.job - if job is None and ct.get_cpu_time_elapsed() < mapanalysis.CPU_BUDGET_US: - job = mapanalysis.get_job() - while (job is not None - and ct.get_cpu_time_elapsed() < mapanalysis.CPU_BUDGET_US - and job.phase != mapanalysis.DONE): - job.step() - - # ------------------------------------------------------------------ - # Turrets (ported from botv2 — proven handlers) - # ------------------------------------------------------------------ - def sentinel(self, ct: Controller) -> None: - if self.teamCore is None: - for bid in ct.get_nearby_buildings(): - if (ct.get_entity_type(bid) == EntityType.CORE - and ct.get_team(bid) == ct.get_team()): - self.teamCore = ct.get_position(bid) - team = ct.get_team() - seesEnemy = False - for eid in ct.get_nearby_entities(): - if ct.get_team(eid) != team: - seesEnemy = True - break - if seesEnemy: - self.idleTurns = 0 - else: - self.idleTurns += 1 - if self.idleTurns > 40 and ct.get_current_round() > 150: - nearHarv = any( - ct.get_team(b) == team - and ct.get_entity_type(b) == EntityType.HARVESTER - for b in ct.get_nearby_buildings(8)) - nearCore = (self.teamCore is not None - and manhattan(ct.get_position(), - self.teamCore) <= 6) - if not nearHarv and not nearCore: - ct.self_destruct() - return - priority = { - EntityType.CORE: 5, EntityType.SENTINEL: 3, EntityType.GUNNER: 3, - EntityType.BARRIER: 2, EntityType.LAUNCHER: 2, - EntityType.CONVEYOR: 1, - } - targets = set(priority) - bestTile, bestKey = None, None - for tile in ct.get_attackable_tiles(): - if not self._inBounds(tile) or not ct.is_in_vision(tile): - continue - buildingID = ct.get_tile_building_id(tile) - builderID = ct.get_tile_builder_bot_id(tile) - if builderID is not None and ct.get_team(builderID) == team: - continue - if buildingID is not None and ct.get_team(buildingID) == team: - continue - tilePriority, targetHp = 0, 0 - if buildingID is not None: - btype = ct.get_entity_type(buildingID) - if btype not in targets: - continue - tilePriority = priority.get(btype, 1) - targetHp = ct.get_hp(buildingID) - if builderID is not None: - tilePriority += 2 - elif builderID is not None: - continue - key = (tilePriority, 1 if 0 < targetHp <= 18 else 0, -targetHp) - if bestTile is None or key > bestKey: - bestKey, bestTile = key, tile - if bestTile is None: - return - # Pantheon one-tap lock: if the target survives one shot and a - # LOWER-id ally sentinel also covers it, hold fire — lower id acts - # first, so next turn both shots land the same round (36 dmg kills - # anything but a core chunk) before enemy medics can heal. Bounded - # wait so a dead ally can't deadlock us. - targetHp = 0 - bid = ct.get_tile_building_id(bestTile) \ - if ct.is_in_vision(bestTile) else None - if bid is not None: - targetHp = ct.get_hp(bid) - if targetHp > 18 and self.tapWait < 2: - myId = ct.get_id() - myPos = ct.get_position() - for aid in ct.get_nearby_entities(): - if (aid < myId and ct.get_team(aid) == ct.get_team() - and ct.get_entity_type(aid) == EntityType.SENTINEL - and ct.get_position(aid).distance_squared(bestTile) - <= 32): - self.tapWait += 1 + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != ct.get_team(): + if ct.can_fire(curTarget): + ct.fire(curTarget) return - self.tapWait = 0 - if ct.can_fire(bestTile): - ct.fire(bestTile) - - def gunner(self, ct: Controller) -> None: - target = ct.get_gunner_target() - if target is not None: - builder_id = ct.get_tile_builder_bot_id(target) - building_id = ct.get_tile_building_id(target) - tid = builder_id if builder_id is not None else building_id - if tid is not None and ct.get_team(tid) != ct.get_team(): - if ct.can_fire(target): - ct.fire(target) - return - if ct.get_global_resources() < 60 or ct.get_action_cooldown() != 0: - return - my_pos = ct.get_position() - current = ct.get_direction() - cands = [] - for eid in ct.get_nearby_entities(13): - et = ct.get_entity_type(eid) - if et not in (EntityType.GUNNER, EntityType.SENTINEL, - EntityType.BUILDER_BOT): - continue - if ct.get_team(eid) == ct.get_team(): - continue - ep = ct.get_position(eid) - dx, dy = ep.x - my_pos.x, ep.y - my_pos.y - if dx != 0 and dy != 0: - continue - if dx > 0: - f = Direction.EAST - elif dx < 0: - f = Direction.WEST - elif dy > 0: - f = Direction.SOUTH - elif dy < 0: - f = Direction.NORTH + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 else: continue - if f == current: - continue - pr = 0 if et in (EntityType.GUNNER, EntityType.SENTINEL) else 1 - cands.append((pr, my_pos.distance_squared(ep), - CARDINALS.index(f), f)) - if cands: - f = min(cands)[3] - if ct.can_rotate(f): - ct.rotate(f) - - def launcher(self, ct: Controller) -> None: + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): myLoc = ct.get_position() - if self.teamCore is None: - for bid in ct.get_nearby_buildings(): - if (ct.get_entity_type(bid) == EntityType.CORE - and ct.get_team(bid) == ct.get_team()): - self.teamCore = ct.get_position(bid) - anchor = self.teamCore if self.teamCore is not None else myLoc - team = ct.get_team() - hist = self.enemyBotHistory - for uid in ct.get_nearby_units(): - if (ct.get_team(uid) != team - and ct.get_entity_type(uid) == EntityType.BUILDER_BOT): - ep = ct.get_position(uid) - lst = hist.setdefault(uid, []) - if not lst or lst[-1] != ep: - lst.append(ep) - if len(lst) > 12: - lst.pop(0) - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - if dx == 0 and dy == 0: - continue - adjacent = Position(myLoc.x + dx, myLoc.y + dy) - if (not self._inBounds(adjacent) - or not ct.is_in_vision(adjacent)): - continue - bot_id = ct.get_tile_builder_bot_id(adjacent) - if bot_id is None or ct.get_team(bot_id) == team: - continue - walked = set(hist.get(bot_id, ())[:-3]) - # Pantheon region-minimizing throw: flood the area the bot - # could navigate from each destination (enemy-POV, unseen = - # impassable) and throw into the SMALLEST pocket. Trail tiles - # (trap: forced retrace) outrank everything. - mi = self.mi - if mi is None: - w, h = ct.get_map_width(), ct.get_map_height() - self.mi = mi = mapinfo_mod.MapInfo(w, h) - mi.update_vision(ct) - epass = mi.seen & ~mi.walls & ~mi.blocked - best, bestKey = None, None - for tile in ct.get_nearby_tiles(26): - if not ct.can_launch(adjacent, tile): - continue - seed = mi.bit(tile.x, tile.y) - region = seed - for _ in range(12): - grown = mi.expand(region) & epass - if grown == region: - break - region = grown - size = bin(region).count('1') - key = (1 if tile in walked else 0, - -size, manhattan(anchor, tile)) - if bestKey is None or key > bestKey: - bestKey, best = key, tile - if best is not None: - ct.launch(adjacent, best) - return + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) - # ---- misc ---- - def _inBounds(self, p): - mi = self.mi - if mi is None: - return False - return 0 <= p.x < mi.w and 0 <= p.y < mi.h + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) - @staticmethod - def _cardinalTo(origin, target): - dx = target.x - origin.x - dy = target.y - origin.y - if abs(dx) >= abs(dy) and dx != 0: - return Direction.EAST if dx > 0 else Direction.WEST - if dy != 0: - return Direction.SOUTH if dy > 0 else Direction.NORTH - return Direction.CENTRE + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/mapPathfinding.py b/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/meta-generalist-v1.zip b/meta-generalist-v1.zip new file mode 100644 index 0000000000000000000000000000000000000000..7c643fb8947500961b148eb411ae358af207771a GIT binary patch literal 19711 zcmV)EK)}CHO9KQH0000801<%#UG-EmemO4y07KmX00#g70BvDuZZ2?n?R{%++s3x& z_X7P7(-=9GT1k=RM-#UW;MhuIz_ATmY0(=P0wqxvcO+6JDa+dDo`d@%?k~K*a$hi-TN$>vUKLN(BWo4z-mu~itP?A}ik7QnyJI%L;# zZv&+Q0ON&Y|7WW1hxb9~v#~dyCoEotjV6oWBU?hrI9i1hfcPO%o9O`1p&zC}x*4XM zr4Kcrf1?;+&Y&(^&%Bg<3c?BYkb1F+R?Lfifb{IyGwdLYE{anIRVL8_Urgg@!KUMA z;xq9rZ?DD9g4iFYK@_%FpV}Qjv-tNz5J%wx`qE<8Q4(Mg0V~-o7JeFUB)D7gdk}l$ zPkxMTo}J(HPY35$m$&SgeVnjq6tfA;PTm{~rts#wEC_(&FRU_+4<$k z1y#d zyY$wf{@Dxf=l-=H16=ITx8E>$cJ~fiY`6Oo{&U!Cu~*#|f4>i}e}Wgg@I}7c+l80C zm+)q9A3pVdk{^0K{BVH9Um>KI-EY5v{wCOm>nI2Txv~c3A~f2&E!N&`=F8IPnlG?O z*H&VOr56UcYG@`f@f%>>6k(a5N+F5wJ8Wb^YFB|O43WP;SQj*j6Nv@yqX*wQkB2bO zLk&c)@CM8D%-K1#&tw5!0bBvvp>rzXiwVmFW%ldPF_k8$wh60k0&5@Yb9uiCLq9%A zQ`q3PsWca-TkPCkvsJ8AM7+XW7wZ*QzGuGJqZdWw>>4ww{kB#r;2}2FvleR?%C(CH zO>kCd1z;Q&EyWH?=D^6hEL<&!R{4__f3@2_L=wl6nHK{A6K{W#gpC0k1n%Nd7Z?4T;rSV?=1cMJ_PqZqyxZk(U+Hgq{OxX6yzXE2-~TZ@y}Ie++gD7nJ0sJ*Mx%ss}z#@C1$h)ie{oz^vZU6F2c4>EC{Wf@a(?6jq2k;sJ4sQVr zzS+;-49*89m**ee58qx~UENR@x;S0ez%}}3?Cs6fdp3C2XM_I9`|oer!y#)7ucG_4 zw|V0yDa`hI766kC5@Ov^INy-O;DsCFwF#Sf3BsEO{(Rz3pcJs;k?#SGzV6NN`G zJ?#V5IQ;2(lg)z%AA$NYKwHJ^p~u$1O<|dK_gMo=0Eb3^4|~~%SFCaHyxGYBvNZ@4 z?B(He>;j4!_uc|%*=WPY5rCaQ$Kw@er_dP>p)LGL;Dsc>9KimGSIG>y*?;f8WUz}9 zEQjO6`ahE}gv<>g1@|GyKNB{K7WjMWhZE%GJ8U_J!Fw`a#r0_aety9)g5~8!X^I`0-{G zO>p{f9&xB0b`G`YNko(9rQ;cVSZqkBTlikGihUG<_;SN^2g@bSC(RN~XtYXM!+%^3 zvCG3{>?c^KiQvXjlCoE9$vL@q@1b~z-GoH}Sr3c6iP9OA1BnDzF~J@{-#hHxHFPQh z;c*?U<`Wng3Xv!Qj%UG=O}qt6HLhPegE6PPEW$I3(~wy*#$M78)*h~F!j@iw3&Q7| z_VEzfgbmfDU4yb5EcR#>0J6vJ(WWi_WF#!%5+SOu!kCZ-meXG%US7q)KcU&i&R+8f z;V^b}bN;`sF4<_e3oCmF=n+4(So#tNjeqR+Qg&XwE(@Q;OnfXP?b+fr+rdIw0XeyzH>66)bv)Pz3BY zh?^ZgJbx3#xXqBdv=~iS0?;~L1i{$`Ya>MLh{S_eu;Za0fM*;2jv%Z;DwN`0y+;}_ z0+9iA7#uK=E9ifRj~2Hl?f;vT%iD9*64(A9+qblHzWqjLW$&&o&W1O@_s&q0IY31O0)YhV^Ct~@(}JER-qata!Q4-1)T1bx>%+!3ym*sS`1!n%cQ`@H2P9qv#3ICR zENgtotD!DBY*FnNFWX{6EKUvaa>_7}(hmjG8;({J)X)(@*GU6tJ#4VCpV0iaB#Rsp zZ#<&jI|rte#6D-B7|=Y6Jd^i_*vu4I+!Ca`w1Gbr?9rR~n2$PJMIr}?Nt4GGrwxcg zlTA~fp~)jo3772oi%tH57ro0n$B zhWVhG59^d!M&v_9ckeoKK*QwEm4~!~&rs7=ynCm-l_i?+nlHw9?j?z^%ZWkb`V5Dt z4-JQn#Gg-9Ji^}{G!adKR=>|pMX-ELqZC3Q-3v;pW}`yETt|bFUE|{v6okxFg<5sK z5L!zE&Va4oalMigR5dIOc%-kt@j}Zt80hbIknrW5{mXC-bniCn3S&Dkqo5vf!t0Sm zGm|)QlP{Jl6b{kmX@JU1h}MSW*WW-;MIun_z90IF%_%ekJI4V7G43>4EP^zp=~oCu zJ|f(vXJVJ0PaMO>LIc7>XKPBC3lOYteLu_!=C~KHLhLi49?TDH{M-^tL=Y<|C<%M0 zfJQpccH)wOsGY=5QO$h|Cf0VVJv8bcMB9_0C%L}e69(cD30jrTY5eaD+j z8oH6%fHAsolqCzdS=a@b7p`s%w>Hg*sj9fG+kl4xdqmgC^Ek7gXsdzFor3q{sao64q& zV_SHSxQ~#t^5WE1Mu<^xHhG-A+NPyYX{uH_4X`@_eSw7!X<*qTG>?jniH!$Lnj<~^ z9tyc5uUV{Ei%jLqoOaeRFz_L$p+F0DYh+99#gCb!sXqSP-rnvO?J1Vd&uFo?eT%(% zEt-w}2*a~%7`>ftt5DAZ+NktXi%EoJKd`+XbVy=alkGTqrqkL^>@Q${sDu=VdlI3o z4$XjJ$_I>cAX59pTfl#oAeE-;V2qwi3H*K@jlB8EViDzJm%kf&@KU9`{PiFJX)#zW z3dJ*Zs?k*G8LXpG@ilwdg)tv?Vf4oA8LNvrP(bG+p zEbB%EbE0i>=bX%`M5X9%(}{<>o?h;*v_G9BiqnRo67cs&;{QdVzQ9pUD&|I3(IoKq z9ATArCT*6_2qd#l7Caza`vqhh5?BhjvD-DNSiY!aIEzz`nE;@zfV8bNRx7KL*VxG{ z;bht-Q~1f{S;4-Y8@Ex#^8=KVxFuNGQ)p+1hg=l^#F2A9TQ-_7aNYcCpF4cW@Q22j zT-qIS)ht1AQe8hA_2bR7xLT<7h--tU-ZTW&Ew~R&Ejn!)(sXS}8a0)N-r*5i7gyof zTiwrK#vaH1lB^8Z{hRkEm;KAZ#UI?M5^WOh)u0%QhNnV0z5W0y&%#HOI*G8!r(nsw zMyQT8+5h`L|CcoPkvAdTi2b=jBm80oLM~ZgRcPcR@Ti^qHRwJ?yvuhe^vf8ZHhV&Y z6DtkBhBH5a0h^MmTH$xS!aA6wGiP~4doyA`ax$Wj5Ce>lfh~D+TC_`H(4z3M5}eXO zY;}!Ys#vCu!6UdLD3xpIGDU+06owr>k>Z6}mTV=cSgw}uy`?@5zKd5&v>OdcStqlgUP$7! zV&ssRjRFMz)LhrZf^&V*e_YNZQ!zxNgEvkKz5mHrwxV@KF_3Z)pndH1S{cOW@XH*b zVt&y@G&!_kkQ9lSv8cj@cU&;t>mxAT;U>=vrV>)XJ+EaXVhH%BbS%M@X>lx(R!Pg| z=k`IBdZFOX&OQwOP`tLw)o30h=tj-gN}%fZ35MOlWagGijYh99aDj)$CG4<`u~cN( zjQxK^=m`&_cme1DTh8yWTc9^UgVKWo=B?1Bwb2MZHjeuR7Y7YjKze>qvW{lB!UGdB9;ONH3mX9UBRMEaK?|> z%Y)}8um?UNYd|A_kb3L|+uwf<8h;!|@z3x+9nThI!A+xx%`rmNo3Fi1^0NtP5sc%g zEm|UYNr1A94@lcc4}0uWFrO!|{N#GY&=wx*V!}DO9GtgrKHR=zx7YpC^OKA7+riHi ze3AsPv0;Bk(Y+u0qgCR7z&qbYF$zlG1No0Vpa^M)y~WrW3V#LS4eBErhnGv3VDb&)Xrc5-tLBVlij&`GkyX~eA!2%Gv`UdEUizPm7#YJ@=-=w1ej zFy9QxdaS}j4R^xS*rWXelrwt=LN+FevUf-#AWS$Xd*}{NU+!bU^x&mx;n{(W&LeT= z-h@*|AYq^pN94fAFM*&DyRX`5Z(n+#(NmaWcn|2e8ddR@xEzC)GAT*fUPcVS?UYDEjHCjB7K~OVTo&-r|aDUIk4xX)j_9=|kBDjgV%C~sH zNyi#J^V)I%xsBop4-j|;dJp;F(|F}`w|tD#hsNLoP-#5gc`0M2T(T^5E|qH9bRI?2 zFA(f!UU*LdaS<2a;U8Sk0G(yz2b=lcQUj(@7(Qce3e&K-792_wbVv5QZ08UBF}KLjN(v7!{BFSvssak_!r(@klicLJCajAQfB(%e@!-AQxb0 zJ!)}8UN~3b4A>M%WSmI@*zt?~L(Y_ia5_oUg)vPx;QYBB0^zvrE!$4qXPb z!-rt8$SXjRMS}*@vUaCsYhE)dqNGN$3t$_XbPFaz`LSpwMB*dLBIt$!rir-`>Nm%R zS7?N)No$ab>a*4UZ*i=e2%>_mv@IKP!Yw20snDLy3Mi?Gg2*$pA=Xou^2VEn-pPSf zV39EyyppyTC!%IGxWtw%A6xO|y++Z1N()8z3`v8KLQ9 zBNx@3MbeHZKrS)YHF*m53xyP7C8rB~o8oZQ()-_YdW5>y-0qnT} z_6m#8HCJ3W#e5m;G_x1JB$8@&_9`16N#La>K2E17S_ij3kEJt1>y+|95Rd>cCnFMF zLx+^&BKkar=;VjT$Ff5`J18YBy}NB(SREEtjxS8|b#oLpGJaH!T&-@((HsBQBzE#fpdcp;{;t)l~$v zi(=4}1;MiF-?`ilUj$QRGlj9#2+idIxpkIe*S!>?1NLQ*aL7|!q>$pXM*FNTz`FWJ zPMGWtONAz?mco2q4FjUas{|aI#8`+G6lFhl?>_F|l~+esA}_B_w66_$EJ1T z(NY^^+u5{P^)+)QG+1p3Rq>SWc`BB(DHA5IZE3FK(pubUQmjx^Fe$Ac1#v1O6}RWwvyhfYnf8KeC&~SqJR3g|EjeYt*HrR>#ip& zST?T3nQJy{*GvD!n|r%vt>e%G^(uQ}F?ASIrz>k1$%7IYSHo2asZ#FeSx!Qt-$r`> zDyMHJr}K%HCdDnK=Q7FO77Se`)mzGIGhlpg76mRo6y02asd8Gf8|K3`ij>8~MPbZW z^SfMmP5CTll35AjYjWBMolvHnfEX;Z0PBQha`8#c8myA!5f1e2fvO1^K06tw(|>OR8$rZ-K7^eZM1a_Rw zR~=I&rPgQJRw81=72j$diX0Be7IEZ_XL3ODw?3roNFY}uJn8e8`bsGf5{^R@LVl&^ zi8=t6k3fLJ32k&S%z%JmW?>#$=Z1?9_Ccw+QEI-ct_iCcBrtVD9tzqZAO%O?R%7AQ z@}0;rDS@OgfeghlM>ZmB7Vk+#FjD=Fi3SDc!kDQ|bZ8;%6|H?%3!i+nikEByZe+H?O|_Leeh-;@josKiy0gk(+)#k(vSjVG4X+lriPqh5 zrJF|8CBNEg|CTb~lWety-IZ4JskYa0!O$?t{&hB4`yQ@krq!Bq*$4{>=43oL+PkY~ zJe21vd>MIGuVV_gEvC~8!~2U_utovQIH^Ohbj_}_3X92wKWHl_&$i(IXkRe-O7F#A zAzc4GrDm);^dA8uSDaF>Y4w$Xa)X3-hCQUP#vrAlp!D?YVGD6mQotJY7gQt)i%d-Guw53>zMA$d=bT5WFe zFQuvKXqo90HBRf2EpQnG2iwT1JF=#YLaqr`=9UM>m})K^Xcl7_vOEKgGTpQDzxK@b zvC_tv}_yk`?bs>WIl95Gg^))^#iOJWIgD`F*6SzZ>7V#9~a9tC+~*#tG6 zbajwMYzeX=d>>fdAfki;Y!f)N?z=4Hl0HSpwsXm1T7ijVb`6BT6=47$tz^ZjWV$7+ z5+t)%G7q2<8$H}OSe9U~!K6AlNQwO*hMrZen>4@!9kTX9Wxkx50S?aON&~{jB+OZi zfJ2x6l&6F)a$&>%pj8h>HQ&zbx;alZ5 zbh_WHE^0Q~pevNq6ZJV-wDXo@Hv!{~{ z#m=Imy+=MC|4w%jK^33w^~oV&ezG{)opKH-W6;B+wI>WIXp9R(CokfmGKccu`7{!T zA;el1VPk*)d5fK%v%OdC{Z}oXAC9*sw093*HPPbEmMa+LBugZYrc=7sfHqCiVGoDO zKEhp>;573C=~MPo8-Pyyc`zbN9*p@-|KjR*VKn_dA$hhyRy|14ngKhws1P}w0<7!hv$}xdzN_m zBwA?kTn9Y)fv1d;(66m){^JWA;!)=Sk0&19VO2q&mRb2Ea$8sVHByY9tmoqS$&zew zg`VT^&vaA;>*Xh1&|bu_N;=je2ux^#q=|YzCZfoRq7WTDz?+&eXER`mI0+j*(Uptl}t@u z7Qua+K{p2a`94<(`QzSQEB|qz{@A}W)h0bgP;+(a1lq5?Ib($y_0Tou@=ZW=u6M|lGne}(Jt#rjrd;2O+dw{-6|X{7jOmE=3T26 z!EmhgZY!|JUVq&U>V{q z`H2I(7*Ci2(z~7YKn3=9-mCef8J?)w$DEP>Y22G@~a?i9JwWivTp(eCAfj4sYi7xyZAd%^O|tsd=jl zKFY4k^lsIgEEBfz`Kt^Z!W`y9?4!V zE5T2GARvZF=wCKPR8Yf}vLGd!@qCN;q^f|T(wLzy=C>;isVRk4D}v|?Mh`vNT~`@3 zwjw@!z86adqSKL5!wxT1HVUf)N_t^0^yBB73VV2!am{UjH zv|^c3ucr7$f}sUr{_ReshU!(aFP78m=DdN>0j1no^>lAl(SBHc%6UZ)oob}~ng#PO z&VtD%zr+lqB#@MRw6P~O#l&58%gkw-fi2Ji?vAe85F%A1mXRH)hUD2BPHNRR8 z`0toz!I}NnG>flk7XO3OEdKKJiBhd7tM@g1!qUoTrcY$Y_5QJR-rD_jK@i z@743B9+ZGLnfa-grt)mOXG6RK;tV?Uti}84(7TLrq~of@fHB?|j$1e#W6Uu8SLr!j zt8&c#voS_fv_~a>WFu~5_6(!*DF3aTT;<=_j;-)rCPMqz#>lvkh*0OcodcjE{(Vkc zsIS>;)oz|flc*w@1V1#<9ErP%Q_sn+=iO(L1*bjm;!I(+rK#XiL& zf{*0?<-v1f{}ys2{yZXd{j*~t@SmLwg^Qkc1Z54TlJwSkb%vcF5A0+O z!85*b<`aH-6>XvQ3}<+W*PwtfkGDXkz{g+#P`%WgAV$2gg<35s9iTW?QCpYMmNxke zlSxp#bzaEpM$-GD3aa*PQsryG2aOU-(okHfiAZDVh>EaNNoVtJStyfy-0m=}>UL(~ zv%@!Bc4g|!3ovdAZzBQyY;y0nV^vO}7*-b~Z;qj+8|FU-czZ~L-&-=qfKZ>VFYz$= zdcviVcolv^W2=k&)t+#CswZ7cNY0uwOSLND+l`)C^qJX71N5~EqQOs zZ|EURI@dCG3duR}y{Vk@UY3@lK92KUB{c0mkIUcse_UB}Pt~Uqjq&*$R4%bY5PD=L zv|$=G0GyqVPc@t?8kyxzXe>c%DY;VFR@z?J8l&vu9FA1FKMS!XMUM?Y9zMtM59KTZji{J^p6||CSu8=W{Yuz+r_A{{B0GF z;*}z*0N^q(kpMy9ns=6wdv!+MYvbLOSuh2NGcVg~&$8^sp0z>Tk!JiPz+9tVXRl1? zfdvy`aj0+Y;=thlZTh1v-?aIwGNvompVJ)px5s`U;GFo#7xv8SjN&2H;+q5emhThG z#?&6b-r5!bh0JA?5=kkil}JvvrIwY#;~KSk&P#1lBa(zQrKYb*oe+*pZECg&6to!! z)}T|Dc#+nXs=Tu`y9&QHb$G-}phhonJTFG`X#B}`o20!J!f0+qTmy;VcNbswf9v05 z>2XrrKVs1;U9M7o0sY9gUD-+}e&FhcTzQ~O&UKip9Sshz?elv>=h+t%QnKcGz9OS) zhSwk_snBa;=n19UfrJs5@?Vx;We>fa^s1DR@DXdTg&xwgi3MYq1KtX`Rk~j>!m9hpSn~~K9%z2ngFVQ z@hbe4XEaxzJiLkrat5WM#dBhcO37k#7R0vu!n88>Yd^IjgVGQy+yPmy+z7z?SGuD! zFy`qLBYU;nHSVg3csZ&t*I)q=xizo{ZVCN)2fcnNS+Lm#v&WHNLI(_$k@Q44EZjCl ztEZfbvTn&j;=nfSTGQ2#A6wh0Idlhnx_U+%3!NdOG|K|Kv=Zx+1@N?GCn7tuL0Eb$h;xX&J_+8lQu9?n8qTC()Vfj1|w z(b|uF2c#erG1+J#7N@Qd%3)BhQ_9?u?bvyPxez@ovylZ}=TzR6ex`5%SJyf-T@xH`vI>a%qgb`=_LXh5Zl~A zJf3A|JP}e;V3Z7lua*b%5<4P`A3Ekea(t%4eH8M+3s_hp|6sc!Qkq$Ki8q)&4KZu~!tBk6)w@ z$$@s3X&t#3vsjiAnL^A4hgyfbj_jI%s|&o#?wo~kUOd{87q?8lS;Y;^8LDK^)*k%@ zY|8A|%Ub<%-s8DtYrW56jD<NGj`TYy%Q~n3{(&`i(P;qyU+kMl z8puaUr;%($Ayg$p(#e?yqpA|$b+q|*fMrT0gRLWscJfAe)E>EQk)4iwZnJ=`3C#PS zKAM3kItptgWt}G(fS*1mt-@aB5G$d4uB@m77dx{X#71Te1K;aK(V$!tx6gOU);2i? z+h;#!XQPAu(qzxs%ldim7R)wUXe(z5;S{LSo#MFlJ23l)6IQPa*z zw&oz?ch@CnIy9qEfvxf~zJ`)%{% z7v~sfggm!Ov*mgcxoHIe^5W_aT=`|gzr3?%Fp5G=<)fbJCks?a|K(<638WkbIu)FE z#M>TqEoC}Gy{3rkX@}-(ejZo6t?!PvtEbb_-VHq^nQxx%z=I47oX zDMSQPA^gXZ5-F&e8H1!g8J&VX+t}?KwqU>hKtH-I&Zo4Ve(c_rw8LJY<+#~w8X(Gu z#*0I&P|`t8($1tuvvUeU(oMh47Bo6uDlBb<1?I63^%R+r!$n(WKC#e~(N{uJR+wg5 zMe1IQ9hxXDr=T2_d)uP&z8#ej$)xm_%TA7QXu{Q~#H^qQPhvPFKF#)v~*tvKXrMG3}I zBcy*+cv7?0UTeruu?;osNS(-O6qm!w8qM7!`0U`R{&TW$>D~s#YQqay@HJn#F52=?fnTL~r37W{g9aEaHrU}c z-qJvps-Hy=smM*akNiY<9@U#e+^hB)I5zO<;pvCL)!VnmSlna_A6YDMH$vhg{yzYQbCUSSsPL$_6Dea+fN4UF@K}ms$0gQm z#f(XqmKmp;D)O4}Ty_V7#;@^9Thgph3dzX>2^4+h_mK$>*UETUc?aFpiT9Cz=iSW6 zQ?qo#Qbn<X|ch0hMaAuY=IM+|~n1ot!UJ`!OeJo_N@A5+RK#8XdrzzN>{ zM2sORMKL-7aszt?5B{bz06gLs*qHw~^U(B;vYND+hz6r2vK#bp6mr)7ooM>kB3ngp zH5Rmx1zz8=xKzh!P%Nz~x~98(cnpLDYWN%8oZQ@;J3_(A9OwBiKs1S?jXGyl(Oj_O zij~Ui6pvN4<@c(4Hz)kW_UvR|Fv2Gj{pJIj7t~xC6n@odX}P;JdSb;l-g5OhR2epGATfnUld6} z${-G$$)u7eqm3YRIiw<`GZhO-;9lGBlPXTDO?I;FBofhhhl+Pq)jEL!ISp5q+@xI$ zK@=?xSLrU<2h_|D996@88hY9h1?=A~>0zZ)V48SqO+mg}{_@V6@<-;+Q|GX_O-l;r zh%aSmJL2b3^^*~QhrK<&JimR{zd6b-T!g9R=FAowdEqC9C$51Agt4;TJ)JuJ8l=W} zJj3`tEpZZ~r{kF7EO;m`DH+O*feDI63DG_t^h;6J7&7YvNqkfx3Av9eZcUbYpYR@} z2cae}qJ_`(=m4)qT1_z(gM6s6>q(?SBPvTC)i_O9g5e(tREowPKjH!U$Wt(>$Nbty z#CT8_$17EO5>Br+=8OhDp$AQkp?x=8>Nh*+RvtCIdth;YWE-T9MkhM;(Xk#c zUa(%5J!ku>-N8z~H5K;%15ir?1QY-O00;mkT>@Q6=fn401pole6951b0001KZfSIB zVQf=yVRvq6Zf7oVdBs?5PvbZc{*J_duykMAB`rxyfkg`v%W?v#yr^`wR8>`l#8B_1 zcI3D~|NV{ayf}_qZttYKtQMN_%roP8dF-;gLzXGy0uh0^U@XaYcb++0qUVUk80Cu> zj0(<@1al94BOFJ9FzTU8Du`H**D02tUkPV)iK+0=JbU<0oK*FXb-ufax5FdNH;vg6WWJ=sVd8>(Ytb!@PXic5mQ9wD0{ zeM#^==8)zpkwJgpp};?eABTYW+4r>k0K`Wi2w@}N2rxn=)R+lHtxO~3X@7vfJY45-1R;AtCHC-*QDYPY=HWU<0d$hlAAyj6mw)mQ`=$E98Dp;A=8t5LHn&Hn( z?*OxqUQM)5+ng@YUc+0f7IdfX-9~FyaCtvS)FLK1sws(neZ`p=h3jm?tnzUE5HXH3 zSb|>{j8n`rb3mb)5=>jAVjG5HU=>(Vy&~ONec_pL=>oW<@?_ao_T4Z|F^#3GX)tRy zk;q3lgvR(?j$(E6bd{_{nuzjIWC8{y9U4iPYBXq_gBeiM9x~1sy$0f1+G-YSx5e5K zg=RN1jGP4lqJASW{N_BkQN`sb65W`GZ*x!~Q79OnCxl|{t)OWS4dBo?^3}Q8cJhuc zSGDv6J*H{`Tkz}Rb601`T8ah9{v@whmbAPeb|bcYA~ckn$y!El4%$|=qZM~}ta@pC zFBZ-nEmQ)-bjn8bXaA^ObCnFPxUIHLY%E?yvfRPO7N;+`C8tiqwBcgm2(q^g%jS5*ejN?}Q?q}FO4L9hiYn-XL2~aC;_hJ-V5|0={_C_9H!vCQ` zAj>fQQSBAp=5Hj1dvv>|a#dGZVDhr4u}Ddb4qi0~BsjEL0}Su=Jw=wKYhL9edC4;S za^^L<$R@e%W3p*Pn&Yu)G@2b-?7T_)a_>L$`+?k$gJg1 zL6VzowgOMe@YMV}OZRScIUiq-&u_24-)wOypRy)!4g`l=!b{w)E^qF~li%^N%|+w8 zhy6Tp9R6~vd|1QY-O00;mkT>@R){0y(a z5C8yzL;wI00001OVQ^4kbZBO2Ze(e0XD)Dgg|qO%g$Nqu&J z#BmSU8O-eo{ehE-9pz+g?{+&;!=~bLk68cF}kbIr?Jd(j6 zSpj68t64FH z5hDm1kFlKsY4gdH5jk^E{@xeE*U> z=2GPAPbjX5MkE1k=ZN9q0~RL~mt6qxzW_Limob9CqyxhE0DN5fTX_FZ)MvPe(<|Z! zaRLH+_yCD&0vnwD^l|+4i0tg?~fAG8y%k zMDmW>2H<5A`-jolYx+>mpHs!) zlOysRNC-?R7@Zm*DI(KZ4%TrJp=4l5xJI_fDcCDFK(@|v*f4$2&TpuqO6Jc{=z{9r z7uYF#TyXNm*>c6oJ-Ny>t0<`9voS}zhnxchP3f|fq$&`w_8VGeIRHJcfkOGV0(+BW z;Wb65gBmLF*RbO$wMFrGC6%-F(FWsW} zP>bS6TNI=Rn#ywORj?Xw!~LdEniFa^hr@R9H0QlnmWMMn9aR>oAZv*ZpW7mBpw*-{ zJZKeLbDF`;4{Dyq%WwFuw#-eoP9k(1V8;pHVp)Q&pzUn8EAw7c+M8w^Rsvf zlj5rdgMrP%2aibsRKTSGXMQ;#`$r^QFEJz)q*(cz6o!UR58*A{a0UvwYtY|g;CgYy z@h<%pDaz%Nvb-edy21o@Bl*c+ee~I44uOH@M`F1Vos-!th8QuM^$MEIJvo36ejm)& zNdhxr|6OJfrXC}qCj|k~`U$zAd4bMcMnZ%|>J|!Mcg(p3cRnT6Dp%_ui9--rkr5D_ zSSR-&aXQZ|90jGRLLwPi7sC5OG&Jd0QNb!U`9im|Y%ca;BGTIvv8lvT|Yl>nI zU|3Pu93qUBD5Q}EN=cqA~g zBBAs*E($JNl5~)>ouO)Tw5DD~)0?&peetGE z()yO{AaCy6iQu76p7720q?P1dxlyjVgOl2uPbT0G?}>2joI6PC9(*$4oA>w9=P$uP zE~Vq|h0ON1+*_-`e&r-=u=Of}O#e;h=TVIW{S%}L;q^zA%sRbPpU2{(ssQqiRI;t- zy#>$VDW zzOADZYE82Vx6btSqopD5O;6AD)bPPuSU!#nw&wd%y)P}1!8<6TES1vq##W`27wS#8 z4c+#*`SJ-|xe6&4o?W0lCSQ0rAxvN$iC*PZu?v%~lwx`V$zxO2HP3pH*IbvhhAJDz z_4VZB=E_VzFxgHgr+xAK6 z^($&qkF=oZNQ){Ma}o6#w9KdWOm44I`6_MQ0luJ7mFTFP=etJGCf}2xY-w%SZ3>-@SO(JIqSGx?0i`E705E9 zunfo<_64eP0p209;h`#a0B`{{81Xkiim!&V1!P<*s5Fg;d)Kr)N&ICH`2^(5Ci_#K z6<{B3R{XlgHS~UWbvBhPWErU4acD!$npl=vwBf89PD6iXEQezRFhleexa7>P2RcX&qNLx~iEi)Ry ze(cMS(z_UwC-KxhTjyA9ZhNNZo_YhN>q1I1I~_ge23&IiTfO**TRTyC~YC7@*Oz`!^(_5wfojG#@#8#W9$t@Tv9C} zRr=#X<+2}ET7!gcpw+p~J!@~sK3J`_I4NZ_P{i#X!LYUG+}03_ z5Lh1H@I{he#JazVY-T~_mPd15i^70APKn1r1AgTf@`~_!65zgy&+q$)Cgmx$V997m zJeC@hkxX>e{U{X~z6xBzPZuaiJTs}xfSk|KyTXVt2>8-2`-Y86|6-W;5Y4+c`~mIN ztF62%RsxS4lk^l0+l80|A=H)oG*j3f;sQ=yc+#6Svsnf5%nibL-VbmH#=V~HSnPg* zQJ?g-0AY;ycw;`h?SHZX25UYTrs_=lfhW_}d<1rs5~UqXE>rBB!< z4ykK%pfAQT(SLjy2oms64;emeUo7Ir0sP?AfZ)r9_>PfH25Dk{bn9aeW zFcRn5QJBfStXs{lsc0TT#U7`#n;2W`?rQyxJpzfk*(Y$gkXv^G1n}Lu0g4ep!|n)h zBM}&H*%HtMsh#G!Qm;y~j$TMrf_78Drd^}&piY*RH$Zd5H2pk%3)RKAvungV=NDP1 zw~lH8n?6|(%|G^Q$5;a`{jEH|?rosrTX+yu^mFa+$;%qpj9A+`Vr^^#n{!`WjW1kD zo8DNhe2yt2t!`b1*Lco?t-nTZPcB05>`8d^7&4Wwq-D466blsq->KCX&Q_f$X`(A( z*wH#S96MCO*mn-Hys)aE>Pka(TfudlcQ72>8pe?*tG6I(qDKu;cSR>yB`a86M2jw~ zuNG}TSE3{vy<5@N645QP1R;8~=)DspVX?yX&D@#q`*QC+=Z`b*oO%8{^UnKw&OEPQ z$(dpzk(?)xu@_3w)DOsZo__9B7g;5#5GGy{L*KIO+)W^<$#gpLU`bzzJFR4Xk^|i& zM|+IBUk8R(%oDCV7koXWqc~5OEXI6x2lO#|CZU$+lGL^eRAtoa^T(jOiB4FG{jp-W zakCTKy5RSPi#Pp4bk65ib{H&__mCS&6PP}Zt~ziV>5x?>MTu^S zYUXK>UJZ33)8gP)tD?+vw>zz8$+&LQuO0sii$#U7uhYZUpRKJ1J;K7>zw)H@zPUUF z&b2eLYVSB!QW!eOv(y3BVm?FbbeLF7E!;vPK+-xK)g`o2+nTPSCH)&0Fs``tp}SbA zd8Gyxf#AISjIk1%iT2e?qc(@U=YB36sbf0+ce-1?jOLH&=N*&=lZy+x=BkPq<|)A4 zG3JsH%7n85aZz+qdvM%HSY1EXj)D(>kzq1tpz>nS08#kH9?9hACE5CmuTq%P z{SOyyQJq;x=^qQ&!Py#hi6U2t_>S7M9)qE+x6HXKdUBPc6~%Fu@$v>Z#+niF;b)Ee znR-@m{gbZz-AwIek%1fBN>z1vg1a6fGH)WXQagAeUVZ0IG|+@Ioo$Jc1g~3^O-!>C zf5nmPip1|7(|%ck<=o*znx8cTJ*V#;vLLq=6K7;Q&TBY;gZ*>XST?EYF!dh;gQTgF z1%}KbX_$%qmu1IN7@)>PxS#z3ZW6&=mt4V_8=xvsnF@P4Bg_8bfc{p4AL7F(!}bG# zO9Pcsd<=*3@Cg0vn&zrw;BipMg2g_6I9Hi}N#+;t!)n>&nW1eu6~$IlzBp82B#Z@ zl)2>P4$F*bR#?E<2*o2Rz1qBqf)((+mh0w@TM{b|RjAMbSm{{j7#RdtV%y$im}k3j z^yNhiHHWt2wN~Dt{M9X8gQ(NsRnYe@0wIgc8Z-y4JVoe|W~~M^b6u$Y>|3{ligK%6 zjwD8bdE<%vYEaEWF730=%a^%#DOfEyjUC?e7onQo{BUk-5Bva{Ge?)X z4g2$`xd%;tNZa-bbx+&Ne&UM#GQpcEn5DP7egv#?cS;x(3>xP<-(b0KP}VDGoTk1; z=0(EMWw=tDdLBDsexa=`QuC|Aly?*8{EP0NxPvb^40d*fo;$9P2h|ns@D29#bn=4* zUoq#mP>A=89DTU_;-wGRj$bt(=cf}mi`cc{Nmp}GPt<)e3Tn$jzK%D-Iwl_WU-zd8 z;ZtoWe(?KjF)PwqW1aa=*!{$X5g{R=4{;u5Cc378`-yvDnPk;;-YP@M<&&!g{ZmhvDSx15;v*zs!zHmFp)7Sb$ zr;+{T$)~87#mK0QtM9Tyu~BqZ1+uM(vd&BCUXL|o5YEJ2cPff8WdkQZO3|jv(9I0Z zsIC?>QL|Fa(ZMjLP>KSgi@ppOrN7^Or|ZOU;4_Wbo9HnS+~utAjJV@7*>vT?d`me+ z!5!1Ay4Qb3TfZ>=I59kMw70fbvV-m$s!d2q<$s+57dEh{_0T?A&TR58ll>~x2i9QH z74r=dpzz5xy*Tw#mjP)7#m1yXTxz|@jG4?3rmT*-*=@*m|3rcB0c+d*aJdOE3RTrt zYqO(eD#CceWVQF{uId;jx-oCUt5iouq3}~CY)l5Uc)O0CVlXUuZbT;vC z#!pRc7(EnAwxFlAGE7Hb+d{=tqgXj&PaGcUd{QU2H8eE*+72bO>S*;KVHTF)F-zgd zb^v}F&X_qNBI%*3ky(PY)sw(+w-%0ivl4*WhT+X^r#}ft_nR7bItTz`8rT>;wn^J*+Bt1uA`Z z(1gP`jVadstw0I%tAw58~g^=l=~D%p?s9B#eKW)ETOj&rDTxwk;`G#z?D z^b5zq87)EJP0;BZ+oAmYAm&=|Ud}Z~$fk9C+`eV(NTW9Cu4lhf>Sv7mV)fV*7_ZVi za45o6=;vM^D#Ov^Ih>v9)qI~n5I<+`+q5XR&=s~AKZV` zdMe{F^S*UvE-CG<{&;)mr^hGNMB8<~VbD3kCuo!lc%Mw%dl%|v3TJ~5_=R~(kF+MI zhm}YkR3T4=jt$5pzC{}b-~)So2L0uP$5icN8GchzclbNhH_=TIH5Q1 zDd32Mdz^=ndDv!Bd7toPCfBiq*iaqXz4Lic=OJex+2m=$2nt%}@?g-H(957&aF1(I;|-Qe_7ed+9A13Ez8_wVQWPK? z^I6=4d?Tk0ms)zTC{cP|MKx`cV-d-qEAOU9P;r!ZlnrJG;nP}H-loNTOxt>e>iOu) zpk*7RnZD=~qDL4t(31k?oL{+<#3al_|3Cffs!#lF{U;0T@1p-@YW}6Ly$TZhZ;SJH u{qG?DFa5U6p8;gQVfo+Xzc;qO4ZhZ*hZ8FM`4#eG<>V4|HRDJc0Zy1iwTt4&c$B)l^ocbpN{WhF|cKPFG z1^UO4=(L+!J6;=&O!1#JC3?Ny;~r=K>h=2R-|3GvC9D@k<;`DfOPtJebcWX2T)U0# z@9}q*==2~19kFvqqu$xWQGdCA?%4j#|3Cfgzdv()`inP=dK;~1a;&%cSZ~X*-gv`{ z*IUu!azXQ(Q9l$d$D;CAXT11yt7$lzE35a~=0a_w`B*f_MY*=3RWF<`yOIlw$MV93 zvNK*dDhs`bqujO^E#@K@ozpFgJ}#(Vj#S~IDx6y$s@98f^e)XW%fe!K{g(^NvTz|b zG+Gvg1J%$#)j8CKqR!!RG49^aIbA5HL#WLBvMdzixu6DlqzlC$FX{mizm%gi(1R=n zWUyR3d!wCYFTF^#+*f_E`30b>OWOk%t|=Pj&%@w&7M-q+jw z_$~i=ywPM%8{s$Blg(SUY&rjN$3c(W#9$e~TwYDFEOSv? z?k>x0VPmRjled6Hwr{Rxv&e43GHd;Se`>ucmRWO9$Mdhoa?6FbKe=dO{G6@N9zSzD z8QneDdb+v$_~&4M$p3lVb2e>kAs_0`O_8(Tw$Lo-bJmjCkfJ3l=v>P0?2p-@`KCk5 z#m~?o{H#B@tT*0t{>IT@f2LJTd;D5Q4O8CW%0|tMX27hB3d`to~e>k~l!}!OyzI^=B@zX|E54LXUAKbsX zGTgE4mi@z(*W7!};vKs#Juzej@~)+`nhvWmgXm@4DvP z+b{gWp>=odd+nbM4u53-Irm@q=%K*}`g05Z`?Gg%X!yEd95d&xAY$h0o9$`S0%E~j z`dOE+^Di{APyWmJtn)XSI`v1RMA+U`XC~*LmDJ4K{VKwxNy*SRR7k&4+rl!I9~;lm zgZbt84&r^r04NEITdGK{&kM`7K3k+(sm(&m3NlyibN5+%oaaNIo#>V@=n(xc2V;mq z5l71#QWrM1hKRRor5xU6JglQaT*s1S21tH`DbGQYgOf-F>@$9#*bOzn9VmTS*5 zSM7m$&ee8mV_*(eM6XLJ^dOXd^j<{Hky;JR_Zq|T*Dh5l*;`l}J>uSTH13L)nd zLe7;4IEOxib7i^rnVi!|tB`%KQ22&N4)U}=FgZQvf( z_qoa6eRJa6e8IWp*2EE^jw*yYsu1d^La1*Man31(nivu1Ttu963aLGhMNT%T!(}B? zdmiUplyS~Q#A2jMJI=W*v-rXV%bH$({^s8JC$_#~{Ic;kj=s>}`sBgQ2QJ#v-*MZ` z`{q_IxcB)74(vM8KeYGe%ZHb5zxJBNpB~PSl4&(d{B->ODTCV{-gRKO{iC;ExpeTt z&#tV${o$S04etKv(aVPSuU!7%(v7{jg~dkV>?_Y|Z1H01rMAVlxh)y#trtkd5 zUeYl7c2HSUzLtgonM)=|A#}_&f~7$rmUpWlmO`!|mK^IKZjNCLRV$XOtsukd(^x$f znYm=BRXe?EZI+s?)>ePct_)IK@V3`&@6iRfA#c2_bymIowfUSa=lrTiLxahk!@^FJ zmuIwQl&&OgVMHvkW7Q6q6+&J{WUjGZ3aRyA(AdH#GvBaWA!K<(EbmqAHMtyREMHWS znX8V0r9Ub%!^RrtcA%j{OF4E&z^$~^)v?msICZcEmaVsj%p$a=4hYGp5Ry?Nw9~OR zl2Oah8b%qCv6OL}2jwlx-R6;u3LzO4LNaQk_B14;k|7xtV!KC`UfZubNVe4T9ZP-J z3kpkJvxdV)OSARB0KtYk>l9M6hR(VuWBEAixU!SZx+vqUQ%KFWI_s27&4N1XqKvan zA+<4Z)@2!};{23#xtwP7~Rl7?IMvo&=<$T5YGV;X^eDTEx;2sow?a!euQm`1=cg^*(ku^oqs3^}G`$T5x7 z#(*3vWuS_qhGS(prVevpdp2q6EDe;VZZvu8cQ=Wur;lzlZ+CcP*WQD}m4o}QTUyz+ zb6ao!u9btmgNJTj8Ln*q^vdD~`h|b|4WkeCw|?F5rbq7m(QQ}mKkJ?gKDe^}O^>Y{ z=pB6j&D)m_uKexMb>qT0{?jKm&he5B&Tx6qNmL;N)tmfS<3X)&^2!^FU!_EM%2T~y znx}%Jt(?Ebs>8Tgbr|hrL8mqhC$eE(hqWnR!;!5>q@yNff4v28T9j9x!tz`diEd3; zg&0KCkY4pUjQzTmQFo-EU<^q5Nx^a&dhfca*K519sk-cmp>yzhZQHPd*v4lS0W-~4 zYppb>&2~a6i0vL#kp3Dy0Gm}i^dL(F(+STKo_bmC&p8Z;OMv5AqK5QBUEYeZyi=RE z(j800Fi2Xb7zRT(Z?5lI;Na-Aq+#}VD~cIT{&+Y|Sb= z=rdAGP{C57DNm|dmgDK^pjRZCb6&B9ZlAmbCaUu9a|>O>CAu|bi5f!kspla1O2kK` zJ5nGYp1i-j&~bh*_>QUbOY&*gli^m|WW)I-`IL<94BNCQgyd5Q$)^yKPa(E_UTMd} zx7=$C4;)N}$+;YpfTtn(%2r$g6f4WKL=7SNlnlvNBHl_jMTTi>`jw^)`2*yB<|kKv zcj6eFN^r~^t`^7Ogpp$!fv#x;no}a^8d?EkM0w&^S&o6BgIIjk^0dg5 zzt1gzV+s3GK-lCiQ*ZQcqv zR!U&nn(^;Az90Ff#IePOV|MzjHED}ej%ftCrV(gPiJ)s}1&k5piDP9sh7zq?nU+W# zDKYmSft`FIyGTwAsE!nzr%ew-?7hx%DT;uNXgl^!eV_ zr_AkNJ-B0VX!YQ((Q9|SY5!>7>h|ru_gr(=u64)kpAGLVgUhFn9)ZMt`(F3(aOKGA z^`rZ??L9Qy`Pj}s?;U*0>fYgzZI7%heb^2{OgCSCck`Gr+>qru zraFUXvB<88_;NW$jL{O5vy^be_6IA79k-|;77bO9UK5SR9t^68#lh9uYP}v*pR~V5 zJKfsCmWPF`O~8%?nE~vk7h5VUDZpUz(Zw_eh%D?hU;0T5*A`RiUIjmk$;;G8Ehsb3 ztJ4nV8bMhtT(n~h9SO`e14-~kwLhr#IR<~;e!a&vq+e~N7oO7lY-yiLpL^js!CXA2 zdGm?62nBh5>0RSAafm8>^J1Yz9*fMH^n5Ny7Z`y-aV#2{N;pFDD1_uu2+5-ml1CvV zk3vWuh4iY=L-HsYl1C%mF~DFmN*krY26uRv;Ge!f4K`u{vcy*S*3c1wE@}k&s}V*| zu{N@#l!2BadaBh$mXs2hRP?OZY`w)+$Py*vQCgPq_Uk>aA!Lbag)C7BS)vfKq(q8? z1bAdb0lwLga|7tUMN&zJY+Io0}SoL^k} z#`VbM7&b;Y&0hovetslNAwnvLfP6Rz%#%iikT|5g{j3uieRtGVWw4q&o%} zY(_!cX3J5~ji;r-hObSQm=3jujtG5yg+Q~l3^Yq2WJ!r&QW2fh>LN=@2~0P7MwTdq zEK!J+b$aIQ*Lz$;$P(2GS)vfKL?L8JiD2Rw2c6c5gZ_9vP2AFyCD{J*t!+HeQp1w6 z9K!|)yxxa@X9-8h5`~Z@3L#4rLY63mEKvwqq7bq~A!La{$P$fo#{h%P(P6G&sqv>ez|23K8t+7od;Hr43RAurIbPEU#$$n(~v>fGI#kE@4I2}=pC!`$BsSl zu_U@7OSKXRml$o+CiM3;354e*MM64Yu^RM`nzMv4fVcO@ZBohyZFwM}76{%3O;{HZOVU(idTCq5!oC(USR1;c5D%cEOh42X_7#l- z&A#HsCLM`t^0N*n@8%kb2#KTJUmmr32ZW=o5RP_a-w1S7@04elT2 zy^TNNNWu5xNZApZ)}+uY#9F2hj+8<;QVQWnDa1YV*aMDKDT7C1-+AVQ@UWcK z?cFsX&gzIbt22V8=&x~SDEn)FIZ6`+pMaxh`Qg^+0im8Lgrlbrj-Em|dJ1umE%tz; zSIU6C$l|7B0d(C?Yv_AMqOT?4TgL*%lOtTJV3IkkWx^kv0>WWc2#2*qFswB2bc3^j zznn=$7!jv$JE;i7=^GKJZ$zBF5pnuP#ObRLF6tSVMQk`v)BylrY5J0ALmtZ z{p2e1D5o~@42n$79KU?)OUFMp{)W+KdRxD4?&71nCfl#SXZ=k(FFrcn`>_juaQ`13 zc*D|-+b(*b-!h2bIQs40)+f*X_QR`J3=aR{(XYN~=V!MK-ukhfAL*^W>)`1hy70c0 zbuD}NJ73e-!{;{k@EiTu)iRE7m6i8J!h{&ipG8GY@o`}p>Yg?%HU|8+7Pf%>fCnzFzn?&VF?ed8V;wlZTlLe43KoKpxnrw~q%LO4MRA?FlA`86Wbx@lngni5OPi-K0&b^pP-14bE-CSP9av7 zrYP3tY8??*-iVNMWjmNWMrG>~=kh+n9q0LJti}kpchC@Dxp|HVJ7+I^x2791d?1Ce z+X~^NmI(A0`xszWSq}YW&uWPKma1npnR+|!TgKYlx6Fw58O+jst|9JQ=DIL#e9mr! z`%oF-;(^L262pVwk(^TqIj0bEP9dBgg^+U!A?FlA&MCwl5fv}jo|K$ZwYifPYom%P z8LG8HsJsdx=Sl>Vhgq;3apGJ)o%QHAaSnSApuN3}*5VI??;66+DH%SHLh9{sQcD@= zFM5WdF3X`*>{$&V=ah`QF7Y|*?T~Y-Hgc{+yw6}(S)Th`L&&*O2Ghpp?Cx)Td@1>s zOn-0?a*6>>0de0lBJNv8#C^+%`1C}?eancrZy6EyEhFL+6cL}Gh`4VV5%(=4;wl;u zSL=wl@Q| z%lq5jdEkQKbr(F`N#8te^j_>|Ty${P{rw%+?OcD|d;jv9{zXSt_V%xNX!VlejTijY zHT_op=DsI2`I~1p`I~$o(rsLOPmviMsFY<9T5ACiw`CFGv?&?4Wl_d$Swt3=`BGm* z+{#77GbI@T7m*L5mCNNI2%cuX+KSu1C}WqVR>r2@&cZUT(o$nH7kVJ}hX&35&{=6o zL6>rNa4`8?zWK>+0Z#4YYu@mrNGjN+!4<@ArmY}@Dq=e<6&WAV*n@>;+#=#*5RqXs zIvK%?;ut5jBbWPg_Ns`*=hZ%QMq_RDb`}~xFv_@oW<>0Ro!(@8AK&bQ;m3JShm$XG zPLWq2ORe`UF6PXNLOjZgGGwWeAxjnFQC_T#ELAcb1%;5MC4xCc1_`rJmSaxwoYX0W zcw`uRMwTj>dOKvPlA(r{2o`r{ea2~V&*wOAd(JfLLpoY+p*3~PH-JD2AsrP$Ix2*8 zR0xMoA*7>1_^=8g9ZLjLhx4MKR#}dz!*i033bDbg$hfl=N3Grt>8RRq2QAlzspA~* z99z!$e4bN>%!0G7z586|RwCl8i-@x>BF?&qIO`(ftc!@VE+Wplj9}`JLn7;PIi?QJ z>8y*0vo0dex`@=%ujM66?JiHV2@5)tvmCn98>>VYpYQHHE5 zWiWNfB7t>fxj&~b*s(5;4kJR=soLu8kabGNm;c-jrVb-*r$v&j%V!&|;3hmW3uIk+ z5)zLDgsf8tS*H-PP9bESLO65^A?p;vhgAq!S0b1?oOuQ7%5r~BvQ8mnokFN?3aPh4 z)+re(Vu^?|92*+fbJCpQz-99gXF(cvZ56-{xrj;i&ks$I%ITBny`O}5*x#w<9=V9%fY4ab2lx_{T1``^6(lJV^qzV>wudj89KQGdZKK<_z5T&KE1h(!9|!Vu z(l;dIjbsX^RV6tVn{+`;ri{|`X_*CEXB9%`D`Zq{XR$ek8+$-CRke}F3L%dbVjqF3 z_L?b^*aQ0{QbmSzQ0NkFrhlBglhxG3X{i%4t-nvM3!xG3R_i-xZ)rXuaZU5!5Np+5$GNe zXIw;_aS?IGMZ_5w5ocUP+{Q)385a>}Ttu945pl*v#2FV6XIw;_aS?IGMZ_7G5lkLt z!LBCkB!!MFq!T4bm4H`eIs#M2lu!*J$N6#+8WI1;&--UKbgs5He07WSm0CxDvsn@fZ-j%6tsytvTyM#;F%@$vB0OaS9>h z6hg)+gp5-N8K)33P9eTjw<9lPP<*M52pOkp^Gr>YA>)(`8K)33P9bDmiD2@W_u=ij zIq!4S-pewLM>d3(EN2{`S5Vvtr#s@AUu^n%pHZDrIjf;rexQuvRv~juIpT%ul zMBK(@#OtDs%jI5|+qj6hjf;rexQMuo%Lpco48>-s$);?;}#{t+5m|E6`6&{EkCryrUPWJ5lmXtR@Qh}7Tv#2df z65$;r3IQdx4DSt5GJM@QBBZ3M%|G>~WNLl$PrWIbTHo9_E16oa{ex~%#?5m?+&n7; zw@;R1fXQRhhnc#Z^!clAohE&pdF|96(hbhMh&b~i;>?T4f=s|Nt-+%nQO21U5ocaR zoOuy(=0(Jr7ZGQkLSQ4!i)<-9pGT#QfjIM`3~gjt8zzsnaq@D~=UBe6!kO2u5NQYd z26@s3g^+m)p>`{TIvf#ao1=`{k?RtO2H5fD-%wZ4&%TBg=F5>m_5dQCzq8P5&H5hWp& z487A5!JKxkdvWjdoaU%d+dCakZsBBrIWGdB?-Q*~{;Aur2dR*@)5OvQBAbtw%~zsgjd zm#5^2V`*~dB>6qaS-9!UuQXvDBSQ0}5SmVf&^#%GMkpd~Iu%0GsgPRV+;l3LTHoAs zDw$fZ-E>A7H=Pl2)2R@e&WvDATQ>;Voq$aoxw#30@Z+2vSU-8+y7Bqvz9^jVyYv00mZu;9k7GStt18Ap^ethf8$1k-{D!&V7h;F>& z($&GXdmfowweyDE~Zuh7wA|63S#3QJPc$^dw&o)GadSdy5 zmhD8}s3!`co+yNRq7dqdLZ~ORBFu#3USlGd3E_)1ITP|{Ta%t3(x#r++rO=cCBh|L zAv7uxp`NJPs3#?Y?lIQq8G#(@KgdIjh$UT5+D!p*J&B0xNkm*vBI0@y5!aK5xSmAB z^&}#$ClPTyiHPe-L|jj1LsZ6$aXT|+Ic~q@-zGglTWM5Cq%fOD5Mt5_<9m$d_Bnsx`%e)rO$sGU3c8!yMFS} z()4V9KFd!p+|VNK=i3hrT3+@uM(^xR#+%3HX}|l9i)=6XwnvutU%2~|y}LiW`ul^G*BrcN$=vNv zv~T--lOON@&c@T`76!aObH>uRN6*wRK97iB79J75EIcAcu}V80Jw+M+Hho0w8n{kv zI1zw5A-d&I2Rx^L)HBw_kD-;<)4EK(wuR&tD;bY0V>=!TM#Nrl?mP`*!PY*peQ2+V z@4v==YJasN!NKH>XVMG^58H6nhX zC?ccgT#Q0^Vnrc5f07Ys4&GHzAIin3`J#{|FgvXsUsL-gQh!ntspH2v|1z937q!BV zCgaKrt)Xv>3&0VDkZ}qj;}k;1DTItuh<~ZReX^Q;1G7pQj2rHE=$8A__$NMNUH*yB zh}3H%AICsSvLG2Bq+{SI^jh*K*fPOXSIwIbrw ziilGyB2KM{IJF|;)QX5xOCh9|LP)KQKt=FU0;!eDebk&<8NoC#n&bXaj^;j`kLx>w za*T=!5qr>Pbt7IN&Y&pc42p;|C?d|Fh&Y2HLYr0g4a0-G8pxn1;S7q1GbkeU+MGdA z#u*e5XHZ0(K^ei+G5%woAjf}ieEu~4a|Y$hA24-@%bYb6yl%2ZQmEjxU^Oq8Ki0> zgA_srDTE9v5%?9I{nxq1>-$=Jz+RfJoc~{(e1c!{M7T;~*&MaJJ|HBPLP#uykXQ;K zu@pjLDTKsQ2#KW-5=$W@mPSA!*x8R?n!F?5 z#X!bcMBkbnqy)$~g^+OyA>$N6#wmo1QwSNS5He07WSm0CIE{dD8Uf=<1e$|)8NjQu z9QuJ#qbVy9m@**t*|JWG<+AnTm!12PX5-SYPuSA@y__mLy}3@oGB@_ORXg#G(7yd` zGuv#0nxmio?oWIp6#l2JwUeiee`f1XkAKoWPxf|Pzu+mFO9!c^DltrD<`= zS228nxwJA@ldl=XYE|ZHB0{TOeqz^UJkpFZ9%)9zBh83-q!|&9G&6#=H|J_{x%bQ? z&4_rU84-^(BjS-}L_E@rh)0?c@klcxm^$Wac)Pp8U+mKmOQ?y$`8M8tU)5$9b*oOcm%-bKWDml5zTXLxcsRFQp#Ph9399dX`8 z8RuO@oOcm%-bKWD7ZK-OMlf~E@GwW3Gdyo+jud$Zw|DIi>oCJZ-bKWD7ZK-OM4WdK zao$D5c^47qT|}IB5pmu{#CaDH=UqgccM);kMZ|d*5$9b-FnP=i@y-667yA3NznmAE zj=;afybz|2nJN02`KHdlUY9z~1Q6JzQW^Gx$UB9QcM2iz6hhu9guGJ-c~>HsJj{<}emcpcf8H?OdhXMkWYOvN6Z=SV zlPt10$o@8FO_)qF;Z(&&OCJ67)}I{zgq_#1WYCl6wjbHP@$lzX7FS;L=|jCcc3pbf zjz=HaxwLZO_1E;$ah)O+^gF%DIh)V11k&v*m;7<>?uYN+J-Y9LcN`jAw}1DR&)vCa z@6zFIpI;fKOwi#bfpqqYU+wUMCKdF&bQK5=xbT(Hc!#lnmLK z%Uz<5I6I?^voj*j&WtqeH|z}M4WDwk_siL-5VA8O&d!J|q$^U)>27H{M$Fk65oc#c z{8`A(Tn-z6x-4W)HXtX8KL5mIz>$a`4eg|Ds|!pa(Zf;*-zkOsjMe3=Sm7wkT0YoNZCY*%lFJTSl7p8@7e=hHbgrpTyaw5V9>I&bEj+ z+als@i-@x=BmOL8TQ0|xF+D`K<@C`1*qqpg^ngbE=EH3H(42*w77ctEwX9P^2%p~=%ox2`Nx8v&9~ z*EYKhNm$l~8EqXfg4@E+NPI)%d1Auyag_1o$eD?qIFJJlT3(?wqf8TZ5sVrnz)mGm zN61bkLv|{J>@1O{{lrdDp4eHIV`lIqWT!^JPKA)23L!fcLUt;I>?{#~7O=A{$6R&} z7CAe=(SFJ|&M}h<>8RM!?8y@MTpaR0v1zMc1UGEmPN-NFpi zQ|E49y=4F3?vEb5YpuW>n zP$N3g?WN|_C6u5xmbPkK<=Bcl*BNOf8YeoJLut@2ccK+SCpseTL`TG(=!m!z9T9h; zGvZH4CpwpVpP5!x$tnA~TT=8a1J%THTCz2Tx%noCx8<80eC>!CmHB0^9SZrEnmMxL zHE9rHpZ(7`^AY+uVPr+3B=4)Y=3E3t~g)}z*-!^H`w#^?-{>V-p z8$~R4l!$0fLTmkIzJs23MBKlQi2K(W@m3b>X;drjcL#~azs}`QYV^zfYlYCij)?o$ z5pn-IBJN*D#Qp1x_*2rq&gI@`-`|M{iCB&dh{*BhnX;U3de@oLd=rUi=Y3n##(WbL zR3nULql`zh5%FkNAuyF?I~atVJBkvXJBkR2Sk{Gs!_)YQ_b4-$791r*A}WMLREWPE zquQyBn#b)~22;m;6OTvaeA6ROOnkHKQDgYpnt+T4J?3r}6NqPzqKs#cGJ;7!E8v^1 z3;T&B(nvIhFV}=AVC&pTQ3wrRMBMO2#0_6W-0(%j4PQq5DQU!Vx%b%(UqsySWdxJP zj8peO(nZYgmU*T2n?6LyH-%7hBI10Di1SS$bvrKuQ6HGgSZwmQeohTyTRL{sajeu_o zA>R~2zA1!!QwaH{5b~`=Fm)Jd;?YlpjB``ebv)i|bxP4Oy?OR0L zzD30CTSVNxMa1n}MBKh*WO#Oev~fItUgu8F;j*y}2Si3&Q+Q{8+qC6G;QGn#h3TE1 z`Su`Yk1U+95we_6lU=ebqWx_OGkulKvq}Cco1bpl(!9#{w9)mrqjTH4wl96|(0!}J zU7!B+OYFL!-MxH8XZ!flvqpFJCR;Xd+4|JE*Zq&XZ|J{v-=0f1mAHuRE!j*Ynm&&KOgbISqh80Aee4sxTt9ZdVXHyi!3tiJ5Cd_s|Zq z46p+ErKiK=G($L%LQf$Jp>s@Ib`i`FBrrof7L#`dA;B5a2>qeh3NH^+GMpiW*tKDm z9&mPwa?>8Tuv<11Pbchc*Lki&xDTFhm5YAAEpnHr%sX#dn{oo|c z5Kb|WLwV;NGlV$EIg}C15G2T36={aB^Nkte7Hnn&J?Ach8G;06 zNJ(&pG=dqL4dD!F8O)GEI71}@v)0*r_Sfd0kt)Gybr>~Xgs?byV3erEJy}egO+{Gv5{CE}PvPz^+c7)tu_4EgdwwY}QG`Z3EDD;rL4b(?iX7y9p?pZd1omaRf9*3na~A)c~{GM=)Dh^Jp7 zLe}XXfI}q$*1@~(mP4u7=NdxRm36rcxiuS7?|~=u)N?Wg77?

;Wc@9{K4hD9O8K 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

cA9Sg+QwHK1JAC+`odMp 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

sL> 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%0D)F`6t_jn@-6dDDJW)dhg3KSY=5naisErJqj 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

4z?7t_ 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⁡2Y|Vm~=m;p3O<|Ii0Qw|= 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

8d< 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

1s%<0HgBJE(O>= zTh;ejE*Hdp_uvSEwb;(u)U= zpvu(NYOKsv?IZ1}nmpQ5g!X0$W=O@*o+g+(MQCr9R6U?Q)f!DGLVJoF6GMBsHS7=h z#x~YrRrQbbmwzD-g-2Ln=uZ*)nmH? zOicVz!Wb$19Z~}s`FGLJCoo4i{)+tDlE}X~!f*dJCh|`a=HHe?{>_o92a$i<)*_d- zB=T=dj){r<8?9j`d zvLQH?h)(AbCR0YyX@THWB08N%WjE+lHb^@X(WykoMbT+)5Kdi6JmH$p_;JO{s~;iL zw%8Zn1h#Md_6UfS5j$Y}DsXQK3T0DvD5wGS$%q|LY#icbQ}&?-kanU7=*msuH2ULb zmzaGoPo$P{u{?q8PlS5}XJ16@08D_0pcVbD-EbwTPyuMF46MU_OFcMpMWk zpI!S6U|$!-^)IZ8e*G)f#bsqeLtGaHf*!0@vxp0lh>CSlBCd-9#kx3)%5KEEC>n&9 zV#u*Bo-8WX#nB*~UR~EnnXj3nsWdPnuy?PDgjng0l1pZfnwuaO+`~& z8wKEMD!_aMSJTl{zuwQaR9x@JTIwZ=Og~vi$Mlnk={Jw?y_Zo;zXBosBx3r_qp}-J zKiMD?Ng}47M8`!j{c?jyKlc^)e!dwuW^!F4Wv*XD={IyJQzFWoN0>z!MVSSHOo=FS z9+lmoOxYl{NJN%5H>A(O_s1C}awBTvW&$4dP_Q z>rJ;$@4%NADpIAaz>Bk#AHnJjC7V)buq9Ow&Vg?En890l|ZbSx(1|ydQiVPI! zxTwg$(IA%7_H-PV)BJQCa?qcSqfFc8!dOFCP9>tud4yS%QIuIA$drgO=TX@W%9IV- zhgCNy^Mp~9nHz-6_B11}{^X|_Av5agtdHy0y{0ex~F2XFz24g`L4dTvJ z5fuxnh}uPEH)25*4N_*&c|2Gr(Q#3+ppI@pW}Dva)2EQ>PoFX~WknvBS&3+D9$^+` z6lE3&G9{w1c~o|TGG&9*A`vrFqT`|{GdGC6qo{hMz4aTPyvUCBB%-}}glUsev{xY5 zlZf`_QP~aJlMT{?M6@T-aZ$9F8-zW3jvGnm&vA#gaoHdttf2WdZXJa-5w(jrUm_~B zNknY|g|=B#b|bWj219^Ap-rIUqC(qf5G$zry3=kf=jZOBrM<_COZliGN{G8dfoM$D z>FpxSqKt~&q1aaJ4h4#R-YhD+5xYatAhi^oFX!;MsMsBjZa`-9#oeKN?^+zFj#Es? z^uh`*sQF%CM>IB%FpDyZG7E&vl!!9tQP~a3lnqjgL~)=h=J2>E%FNq>OqXImdhHi+ zaZ9+u_zzKi^qsdq^haO)jk7QP!iV2~>tk>H#TPF>@JB!K zq4Q6C^b_yD_{@90@#^AbZ2$R3c1k1h-W9yu3-714BA5O}jL-cGDn9o@R{6Por^M$r z+wyaNLQnkt?C1V^T3YQP)>HYIlb#IKx9E9eyhM{ zV&Rm9NSFgf=u{J!Q-n?xp}A8LYp3V|{iyBGsUpY3(5Y?>PBmHg8s=Dtv@QWh*~+8t zkLBL{t%*?rGYXOT9o~}2Nky1>TM{|BC6RfjAvQZUN7lo|_p$v9KtUoWPZJY4saj)B zDzff1{IS>T7qWq%F8Im&cRVNWwVb?nP;nAo$oBO^U&zJ<+ZN}=WU@wyXBS(-FNcb; zCC3CP!Y_v+{BoR%@XMiFtH#4G$EjlY<k?7BL|oJ*+KHlh8O42df#5zP zoTs&tCEifTB};%Wp4sAX-+oc#?)TlX|6{8I&KB}3+-Ta)5f-LXjgv$)T_Bj2h^8f? z=@TNFE*gYsiRersnwDrMil${0O&18JGlEQ)V%1G9Qx-Borgf&P#2NX1g%d=|2rSSN zK%xXtcp?CKMN=oMKC<@pHlVE7IIdoZ+4b|H1R%?&-I{V)`Nw3ika{@{GtY`@x~`6F zeK-FSG4l$9%#(?0f;M z>66Xr7h*O2!kOQB@N$}1O~)A!oTkbsdym*ccZ=drgi6HKRG?T*1&Y=5WGGfs*&tU_ zfoP`~@-b1dnu=}3YAO*|(}9qnt^A4CH}lUm!B{J&;pxzb>xc=ueqTlxNDRE zdq4rWYZQRH#*+ZZlN*Nw37|lN`3NX0n!>J84shAsUjD=#<6ij_*V1)SXsV2oi2KI^ zA^jv``bos}J0W8F6%8W&Bw`{-#PpMBCyMDOqnLgLLi%Nd^m8fkicDGlM47p25o>8H zf8v_6JOgP(I9y95;#w+Dtfd0QT6!`RYpHCo99OK0CyR=;R5r*9O%ib}9SAag=B*~m z*HT&jM45I+X>llXRb>=2Q%3P>kVKRz5oMkbQD)H~WJ*Mt5-~F++KHk}8O6*j5M*Wq znJ&dnBwgmAKMIM|^dg%OJ=80wHT93emDboEylf_zuiPg=iT? z(E^1sfkL!EJ5eE8M1^RHD0(1>ZbebN43vMw6UMyAhBDW0jdMhq5;1EF1ep?1rbIM$ zLPVKGgODi^wMayn6757$ri`M@0zqa*km*wF`|er0R}@93HrtCVO!ZO{(P@F;R3iJZ zLUbyljze@>Gzg~>(T+rPD$!09oysUWEfAb$1gEViicWJ;6wXAvC<-$3Q#c(UJtct1 z0^k&7T)pp2S+6vJCmAshAg^eu7zY%bC<4lgrf`ZPro_%A7oZnCaV^b1S&X%`y`2r0 z26BB(M_fxKV%`-9$t4k#6^O|tqmDz&yP`qlokYwaiI{g1?L;x}WEAtRK*+m{aP2M^ zJ#o3r<%-bOik>h$iKwkWa3c}5Nklg%MATL^SdNPVP8daPMT0mCDG;<}gyqtu*c|#U zP!>JKzHeN0fXqcblt8iXlZZPwfhaRy;pp3<%oC#6_la)AUQ8g$EV{ux=3G?l`$Sai z`y}GNZy?BYDb{(HDT|&cGZ%+rf>S+|M3h+|$drgOC8Ep|BFZcpgcgY?QzCu^B-)9h zOc_O)1%k|sAk(GT5v$A0Ke-QMUi3tnxxS{OIMY}ju(l}P1(66cWrLI{5oMkbQD)H~ zWJ*LW5>ci^J5iJ=qbRdLkeLzoj-u!(QgP{_6+Q7QD^R4OL`+42A{7OSR6H4qRFn;t zJkYkN zhZ2a|)>W^qL1>eR+9aa36C!FW8iY29C_o}=lV~T3+GG^96$sigLJl@5JW!QIPs~9p zrCB6eD|*79C8Ep%L8e5MDG_C!5K(5)U^y;oIbjrK77g;OL(DwR`j>MEndK{fZ~?`O zo*>gExvT7e7ik25NEyLLtV9GP77@6(AtK;*4nSTJfr~U^8xSV}a!`UY0?K3rvabNk z?qb1AMNhURdo;(+exjtw?t|C|cE2%-o@|EvgL_dHb?!w^XIAv|?^yI?^-uN}c58E6 z1(k}P9>22a=`VB9)Bk(+@Lg|v>RoSp<~whH;Q##cD`%hl?1$fX`xEc^=C?23{oC*Q zy|XWU^w-{Z@6+%8jc+gBx%5T-&LuqQd&|1`#Qj_Tr0=cvu=jWm6W5IV1|U4Z&k`vd zTq|PJJx#cjsMfe1Yf`;!iA%1IL6T;|(k`wchkPF6x39*krJEvHadOXK4F;YtTYGO| zqC1<}zO3EXLd@oEBhnZVs6_AX2oJ&n;+pU3#=F1K`3 zWIYy?$2{Vt6uHQWcQ?mA1-)VUQ$=V)zi9~Br-+TWB=|1t82%2V>Kn$UW8f(h__%1E z4*5LBPft1GrpS6M$T1pN7dV0N9hWSf8Sl76-?&R)c8xvW)g-cO8&ebdw0-^}zuQj9 z3~0ijMBEfvj|JA68(%Zm?;i!fZ)@tC?TJ6EskYu0 z1y6rC@%~Ya4wW=qiS73Y7N+(Iy{nj-@Ef6G;;csX0N)55gIUxB--t}`jd*AKMjY~a z%nZK~IpU_sdMq%`DkmCWz8lso@4Xuq=gwXxL$m8&f4K_Lpp4o zIChEXb0GM%afQzV0Hpa$gwGLklW09A_-utsd^$T9E}RZWQRKL^i9WV^rxJxN z5w(Ltq=*WU5>cc?9JE9fIS@qJxI*LspwC2z95FYE)?Gb8x4ana{NAPpBMWI5(0(Rxhq=|b$x%kRk+ z#Z1UPyXv=CVN=DDM6@-FC{jjIWPu>EKp1p^ATlF}v~f}7K>)UVCYYEx<|fg4Oc2=$ znfSbRQOLBd*!34YAysYA-7n_B0VK`=2$T`fSAgw130tqWMY9dyitgy{cl?7V(OvuK zkdM>giXOpbZvR>l%Q4{M^B=O5{tXs?dI3zVmCF>n3dMpbqIOWMl_DzEN{P5uO2lZ4aD{d60DwI5nP7_Ln43iFF(Jjg8=r6PWd0om^wDc#B3YMbMUEov z=1^p$h>DDqh#4snzr7MMBL{*=>wIM70ie%BWaNmsNwgjlMEa~p`QQ;nN<6Yz9!)PQ zkp!aT+pDKvJZc9~vW%kS0`bU3bc6T4iYOl0j3`L99#V3SDFIBjG4YL(&n2AnZg{VhH?V#8-%57n8WP^6kzeX5k5yf5O*cQy$ zxa_Z2F(v3T5xd6G*iEAKn6RR@Vk16qT@)MPY+)=17*(r6DgaE$h!Q}cj6mWQ0H+bM zS2zTh0H+ZH;55QtS+v}A@o^uuHL+;sX25a`5am;DKR4qCqeZC^c5k_=1z)CCg;XM@ zm5ka!%s3gvwX#6SxB}tZSs-LwMwoLO7c=f4&}RbrmW|ycT8{~9<+98OUTmHhi=Q4-0wMPbgh3YwA~S+W8y7_$1YpZ&g1MJtZW67>gxvc% zzWc)qQ{udCx$nIS(MvAF#uz+m2T_@fvgxoHE)bLz2*WE7lw|~E)~}FA#iY z1fMo8`aB5qnP8sfn43iFF~Mgm9^#>AQ9Q)#Tkig@st}6A6j3`U5>rG)VoJosl!#wq ziI|uJL8Of<67v86TRs!9V2+rZMC&m@q<6#aK6+xxcPMeESiBktjwPb4SwxXCiXsaH zkp;q_3j~oFL8MjWP~<@Xw!DUiB6G}5qV<>{vXv6?u6$8S1Y7xMEYL@G4vC;&l(j&c(a0yJSv8)G{`=%AK)GZLih{&qK&GfRtNC#kK#p22tkoi zDj|C8$`Yb)5DBA`F=W?vgm<^0GSV0(;;P&`J3ziH1( zZ6D16^v6Bo=lSyE9)SLsggx%(o6f(K3it+oi~8JN`+;Bfe?V^BBmbrdHt1*W@mk6J8x??lB4g*29w|;_@Hc*NY$cukH~HWgupMx5 zdh-DnU|A0g*mewTHt!fPu(TN2GGrdk#$sfvj(uR?lvsOg2Vx&s(a2b8V(t0&vGzP> ze_>^gu{zmO<{$Wi_9M^y@Z}HLOWf{VxfuP`Sd6~=;(g~2fBJ1t{q)??og^FRJC|M}%d{`GtR;QaZI{=rY(`{mca`N4a6EqZ0|9d0Nfz*@u~26^KC zdEX@tf?cd4_9~1c2#z#VYdnBb#HPK7IfmF`QnY4CS(2*PJU~*t=B;H-s_pR5DaXXV z;!XAvk-hRo`m^3F{PBHmJVe?O`cpA`ghz_o*5UwoOK3@L=NLkNsx|u4r0TW(wK-QY z^rs2@DH4aiV-8daW4Ic}=*TrJA6DAqEpJH}nIjzaHYQwcNfz3m}3aP2&y%H5j3fK%`bwA;TJ&@d=V7k7a@~4LAhv&JWfz{-pTgb74KxDY}41X z9+x$|R?fC#{*=FHqTeikDF|Um&QLi0(7OVSY&Qvxodn zHXP>Xx37iM<+GQ5LK;p5vdwvaW)Y`FM1@msKfx_uP zaC&tSBW2q7776MX5GYe3%A7}-4;e+75>e&}ku_CygEHl|?D|3#MVT^+G7AKm5>aMG z$ia`ZY{ReLP+R`}S*)uKA<-nFwt0k+%P4A-h}uqwY&xoLP@CKqwMj&65>Z=$piLrb z%Lv-+J#6?-e&ZVL*+v#g=NH|WbQ00tJc2zLMSBv_-U$)y$p&e!KxOA?Pe$2?^^jpt zM$ukISV3J1t{LN!r7TjUOq;NUOutAg)D9V(`KCGgSLzF4EMVSREJ5QN1 ziZTmSj*BvL6lA)Tco}KFa*f5)uUyBvI<7?|#C27mSXXBe=3`wN-|7r;h9Z%T>9i=; zRnhraS0yStAM2`!qD(n1-h-7W*45GA#2eXStsV7#FzPoOVyzV@*4kNwNy+c|>)VR8 zRy0VXGU_-KYpvK;thExAosYFvMA4}1JdH{eYwc(d>Dlf8@g|Qfa->Wf#A2=Wn?sZ- z5oOLJ%!iDkOo?dhgorX_gZ5$7Lmu|XD9V(GG7D60i!yT*WV#fa4c~F*J9bD3zhf6N z$Cn@>thF|Xg=dcnnX`y8MO4TXC}f@tg-p?)&0jU-kSU@Dnv^}(E^3&fgrlw z5$9dvd`BEI{f>C-Psh$UAs%-N6#LUzg!!;Xf)>kwcO&+v0#T-nIu6DDRBVef<+hl$ z62<;hG#L9+i6FCR5YM({1ercjhrE%EPVF19m}jrY<8DIBB8od9qEp!* zoffFBgHMBD`mL~VJZuw1rsNj}6U-^fOp`G+51 z%pcUFOo?c09$`LY6lF?8nI}Y)DI28B0+pSoOc_O)1%gb8C^I9-Y)?;eLCsH3!k9lj zNtx?+05ybzQ;8^Z9$`LY6lF?8nI}Y)DI28B0+pSoOc_O)1%gb8C^I9-Y)`vVW`5cg zGW}`Skhx6uhA2~@kU5JuA0jGb3KTL=hC-%jFl0(pc0Oc^sE{cUWeOBB2ZGFl%Pi^C z{=GQUXb&#KsYG--k1#DVicTe>(-R^(l?~Eqfy&O)sf?o20>P<7bea*IUR`Qw5uWqo zS`l*BA2jE#bN!2Xjv_%t)Gp$8Ttr2J3KR)?G873a8jJ*$sO)?ssECRLm52!{P$cL; zIDF0TX`?n-GZ<$bBfDYLs{ZW#=hVMp0&gAX6gB z%m`NlyfSoKMbpa$q0+|IMJBK;0_*@9WyB7^l@PHF2$TT&P6VtO-4wLRZQvTJ1W+ac zgcYFJ24v+3ESIUO$<8S4iDCikO^;tu(L{Te z9SeJ`XhJ^}p*=-tPZ2trBXp!>LAo@e;TEIZUu-xN3`zRLl&4J#5FG zZt)tKb!{^G!9@j+)91H>28rGLxLX&5_EZe*DMEX5gpO29O=wTYV8%5;?qvdd@oKmH zdCWlUZMSnnM=EB9z#i;a%&Ni(1bc@ktu7a}PwGW&Flr_i`M6&XMWZT)MirsaIYMtL zrY1D1V_;Mh7|jGm$IIQeb3>y!0lL@Bd~ zT4dA?qOu|i&I-gUbYsXk6S0Z1{bLnFZL&FfyFS8+vfHwG-xMzazKEZlxNk2Y#xvY6 z#?MaVk|uh#-%43{ZY52a4~ghmB6^mHo@Wtd%BUSg&qY)@E+%J=f@hlzdd>jp;L}9U z64CSZ5mYuMd_F1v>e$WqAwY>gQ&8Ig31CR zWix_G8!}a9(5HziC8EmfBdGLww{Cb=<{zm+N4%1WDsw$eM{zv7ROwMvDWj-TA{&!U z)GVSe8MT9`vWSAp0`b6j3>hkI$W$p~s8S-Tygq_TpLd%fWatcJFa$7qnGEg+QgCaR)R5`9VQ5{h*YO@i^IY6I_NKSzwIj@Ug zw3R*aYNafDie2XNQT$f+gdtm<&0?Kx#W(m8OBB0I5k+4zY6sDmjM_o5%M{(<1*4+D z^|+wYhD?<*CU%)(OR>wmE`rK7@7!hPq9^pQRU6&0E1=_&Q3qIMB|t_>0ErU7(;R>! z8BqeLD@45U#tR6`Z$c|yLYi5X%_7Yl zamAE~X(kbKX%;b;WYi8~niWyyxVU2GC>XFI!&osFOtTz=#nh*XX?A^tH1h_n*va>S zvUrIrrrj-A_-e&V7_vlEDG^mlM3u9MzGT!6qRJww92ZsQD5$jApvny3%cqGdC8Emf zBdGLk*^=!lW$6-C+AW`jO0SaPk*P#fDG^mlM3u9MzGT!6qRJu)Dhq_2Uq(=Avq6;^ zK&4L;RZ2va*GEw4+p=$ktDG)e3eC&Q0*jg^fkLxDp;@5NJcq)ih}uD+Sw>N_MASSG zG}~;1<^iDDrztcG6q>J#pt+STQL`*t3YFt12Yq}QzZ@DFD^REuC{zj*D(6u65>Y!S zRLUr-l!z(^f=Zi>P&q)KrcfzRsJt$MN>^e}{(8oWpIt?NS`iG7jFwNnw4x=9+z!_l z%d;aIm54?qqS0AIZ8B;H(P$A>jw_Cg#*ksuhD@WfIU1FSMz4=xwAtddSy{4#xw93U4gKUW&~66eQ;Q}yt0c~mt#6& z)?FX13zjI&M!#4OTfq{S6oF!?6eyNTfnuqgLo_3MxPxM;6x*_;%Xa}1L0aAxq}go5 zQYm6$sT3%d%IhNJpDT%qU0JRa$1!7Vz_+KBD`Cj?bLI=nj;K;1s+5Q-XAynLs2xO= zMN~O1?)7pMRN9c^I7Z9`RpyvJgH(Ba1eL8=iPr;Uu@Y6j| zC8A1+sB#w3myFs$R9QqpWr3hFBdE05pvnxO(x-_kC8EmfBdGLk?Yu?-{qe$=FglK> zJK}N9BdfEERxZPli>NRvP#B#(mKot;DUC99=&P^`0JTRi$H8ss`VqM*`d zBi7jg`V7W8D^RSn*F{k2N^Hq?mH8DV=!loXQ03j#fo!Xifl7&}QX;C9h$?3heaWaD zM3qHUIW8Xk-#~AK0^b+fpT~O#0Dh-f{VM zd*9OKm9ga^Y=EzNgvV0Nq8XpmUZ!o`+Nc0t@c3ntxG%@jF&KR3Ae(SzLX4tXS(JHzKK-~ zZZYfDutd)hF41!_Rz6Z}XG`!nbsV1MtyG`KoZ%a($1+1;4Q?K{hi;cIU;?>g||nh9;P@Il?c-TnzQAm@UB@L3@EoJI6G8%1d{YKx$)*Cgeb52=EK2`h2q882Ca|edgCNafQuS(9o4us6@--aQqMymSq$z zOGL|aC@jxKg-Q{%MUa`Qkw#1j`Xq(r5px`Z$X1KQbS-L;c<8g-ReIqIca`hn)2k3g z7EuscAc&kr6geA3TQX{k;49T4jcFAI8EdYm<<6 z@&!LkyII7vE25Bg1;Y1m7Qx+o6mv^%Yl~nd)g~>|P6DLeqB3d39EXs0tul!Z#}}1J zTtAoPEM5i^>*pMb^;1T1{gj9c${eD|b%kZ?eAysv$*3)YNNaGrr4qoFcOh0nF{UX5 zk=~#!#lC(Pbx9Dpu7dI+7>b-l6j?+;WPvd6vxw6>8%0|(YK!2@W@Edh5XQIixR=TL~0Q4}c=M>dB-Z%Z4^X$gZ5Cci`=hBf=_$Aqn{3=Kdt-)=aaLDK8q;$ED#1bi#W@( zQIsa5wg}o%Mben267>0_nPNNTaJNzL*@SR6n(L9UHLrk)ThOD22|;B3Nx?%9MHW#I zSs;vT7E$DE6m7|eS~6uro(ErOX|tCV9((C3enpJR?g$UUF+JM96_qEd+>m#45^ z786G|b0|d0D2kMbw&qZXoQn!uB5I4^D^)6um=eI2&tHffF~=c@Y;{UJOfBk^ATqzd z2V3)^7>b-l6j?+;WPu=Z7E$DE6m7|ZOVH&Y=(~qbO1$j%*Hv$hoMnC8D+nzIv6C6$$m-ObH;;=P#6unBx#cdV^N( zRt5D+otD-`QiMc%AXvIqqxvpcp3Pobd2AP-=F)`;*B<5UH zWS@xIBKS%bN+YHOu;r5!i8*49LlEf=+G6dAS=1*%q}9DFGP0E}LF6o=$RY|N3j~p~ zh$3gBXiG+I5q$Ofq#RQM*z)H8{-}t3za8dB8UWwGgJNg) z!VkP2;Ej0R;SFxnZ=Ty%(P6%Mj!3^DYmbpS%r|5KU%-8wxLbSZO+Ezrjib0bm~R{b z`pt-_krp>2@Hc*e`@D54U*PWVq~BgLZI|H?-Y)HMvht-JK)+lNceV260>E}nfUW0K zP(VMevA0h8WQo%n{7pMlW&`<98PE^E;siGzegU?EW44|Pjsbn=V*4oHbl$lD#va6B zUa;Qr^xIomWxVjs78}%Anj2GVlMu_1eLwsw)XOT*eSNZ3@ZlQ_vE|PG!n!aPy;udU zH1qH}%Wvfs?0-Ff_^G!&^(oX@e&lOke&y`>UwQ5Qw?Fg7U%h(o1Hb*n-#`20FTM7@ z$3Fe;ufFTnSi#=%*57@HuLE3}V&y`~LawE}-H%@f!5;DP`&+`=bwz9)FJf#uN>az* z1&(?by&lz}e;<2w zzDjEB)v@Qldx3Ys@}~+%`-goFH>`71 zt<%kRtDMuB&-JM4c9wsS%;Brs)<;kve03X-b=0fdaK2%;f_pwi#UFlRKohQoItHtU zCdhY9>KLqeI_7W!=cD3rJ5H{JYAcw3z5P8>jdLajSrx~;aEO}72X?4~Bi;M$c=f*T z;oj>1j-%cv%Cpf}&s9+#ttvvRif|972}Y|4=1vp1yDouMRjwE_K&xsi^e{`RaqhLY zj)7J2@-<#e8_NYwSX)6OuYCzbS+Z7-whDwU6$rix1Yy@lI764EiG9G7Cbz_So=4?? zcN<(s;Rt%Vq;aXOl{CWW`eksAXjCE^EfBg|AQ&wWj9wqXsCC|sH>&1nRBnj^=21D| z-3HfDFzWMeUnxH)%IER;LbmcbBw`Jc$&q{HP3<6s9B8=LVvW1(5U{oR+m5A$X zfiS=VVa5ssZP!OIYU5&Z%I0XaKxO~vridyBJQanDaqPPL%-1s^(ywRoXw}3lL^cGG z5>cc?6j>mQt3a5j0&yvoJ)A%gY2%7jRx}r@tVCu1s6=+%PF$-|O+_I!+tp`YCeBx% zp|TY!GAEZ$h&05U6v#SmeO%v}X;J+4iYQ)Qn~&Pr)&zn|>tW=i=wIZdMCGU=yJQq| zQlgy(*HKXElWUppS()z@;1apr6@bxJQU{~ft3`4)1fvqss6t~!T<{dZP!OI zYU83&*&K})sO+EpebqlE=X`_fX@b$FWP1+>D*YY~*V*+Cf;0q`5>cf@abmJutXhNU z<9rlU7Eu!jDyRfjDhJFjK+)hj3MzdD?aauNGv9H8O26YqmFt4_hM-a+ zs+5Q-3xqxv2vb!c__{uVN*fnd%I2uDKxO|ZrHCpAJQW3%uEf;4%0usuLbKl=4b98N zw;^g4C^QQcnkC|>CE_$nM9tSl&}`!h&7!%`EK%9N5GJF_0Z&CibGw^O&G~LNRKCnw zN9;$}k2p32l@d{ho?j3TZ>=wi3XNO97x!0!S4KV=G~lo3;;i zpD@QoLxK7k~JoY z?8GiA2Rs$kp0h$nTj3&ie#+#@>mo?AamCJ0 zG>4ok`o}qzUEf9JfTyA$?L92K@#o)o@VWjikcKF2c~sSk>^PAEg*1sc28k$5qMdC` zAV{;$hcvOJkS0;tznFd*RStM63X7)Cdpt^+pH@Upw5Ju3fA-T~eGiuqR7%ABlZYw{ zgpm~pQ&k}Nx;}!+UZyCUqsju6{iBp3svPiC6jZt`>!Ba2c8&Ov3_U3S7Ph7uc*2D0V;c$BQAI>AH8Y_DkY*yiKwzb7+HZZRRw~|>m#VN zaZ#mgjw%aO_K#AEsB*wlQBc`xRd~HL*Q!8et5xA4>+;y6A*i$;b6Bjsj;K;1sw@yj zRv=7OfuQpG2r8}fR4JRI$^w=Bqm&}59Pm^WRQkNDQpizR6z^wil|}Z)UM6%IbwhTJ zX&=}R5=N=fg_lCwZIg$8!pb81LVC#Kqp~PUjixG#{&P`T^r1id%J1I#7y?_fA-`RsGsy-#0GH=D^x=cOSMh(bU>Kv*w&^mD&Zh+#lLUIggo(EZ>SBLaf(CLwCD z36i#WQHwHliwIGIWs`Muge&{uCb@rWs6>~|6Z4or}sNnB2BO6D9F1M9<>$L&V>?h|FHPqv`F_@7lv`tK zvM}w$wrtC}HAdu7oIzJ5vL`#yTC=xby{)6E@ZI*p$1b~)*d7tvBVv0*Y>!B;ugf0T z9>v%M5!)lO7UL6!sRy!mY;BTeIXb?*9!6xZ?}*pzUG&*3*W` z)9VnD$BHDYj7pc8uU7tIm58hokyRqHN<>zP$SM(83L>jSv>GLqW0X|x>X=3#Uu~GI zvOw~6b)<{MA9enQ z6-y(Kv|bOSFRulXw5ucKUprQeV4F&z_DCIV1x7MD9&!id6M<|ZkV^zIi6CBNAd46w z8wg|&!AgX_G)|vn-D1>hlg5{K1EO>*KyheIs0T&tYKweD7;B51di#4_v_clA9wMh6 zBBvfAryinsKk*?&J}s$@AqHqpa*msdsgx)N2jK z<=d8eSX*S3@$P1`Xe(r~N<>zP$SM(8B_gXtWR-~G#TY6?R*7geN-D=FsT?YjV3@3O zjFHMVfNF$K6Hca+eAf@Kjky5i)7CAM?r^Bfuy;eob^L{OVLsp5%DiK*FBCAAXm58ho zk)!IS{Hd3*sFR4S8Id(3vSvgcMnu+($ifg=GosZfX&$4bd8kL4TWP~&&0~x-cT|p` zH6Iuy&8@*KAj8TctK8pS;pHh~5m_Z7t3+g#h^!KkRU)z!L{^DtHA*VSD5;FmSm~ct zjxkc%M$o>lj-;|R7&kn%j9sfNveEb@es*Tm%2>RdBC=6LHj2nb5qZQA*(f5rL1d$d zR-(Q7U^iMmc=ULA<{FI4ds^>Kvs!S zR*A?e5m_Z7t3+feh^!LPYLrxtQBv6}l;&VJf>t@k3`i=kj#i!+tYfuW8;it(nmomw ze-2xeMPigiBC<$C7Kz9r5m^Z$i$t^YLr*SsX?h6YM3S)$=wyImHYvk znv?N1xasa7v|o2*Y5UL9Y(*}XhRD(oSsEfsLu6@)EDe!|ACaXYT8)ylF-p>gYNdI? zwPA9?jWIIQk)&N6N!nX(xBG8;&5Cn;emc}pW*VT(G(eeYfHKnnWu^hjOaqiT11K{M z&}x)3ZHkh#U0u?CVP+eqoF%6iN$bd&c2y*4v1OXtZPh7xmNLKe*N~;{KewbIOG9L7 zh%60}r6IC3L>_)bd6ok2Yc)#J#s(!V-ttQSENzV04X)?Ft0SGIv~iE?IqfV3Yl%|j z{3Fr|S5hUQR0$|m0!o#Da;N~MN!{c%ddO-m$ggLKmNo!Uq1WkfByM*Kl;LF zfAu{VAN%F!{@Kkx`O^=7=oK6-M%>8W;N;A6c8k{)pHg5&I)ze?;!KcSYn* z8*w7H+NXg?tkp3RMC^}<{SmQ0CYpYT*dG!5BVvCekw4cUT{M4H4U+xE;Z1BK9{D`SZf= z8o;RzNp&mtS~ZgPKzhxFJ&^30s$=Y$h+Px0Ya(_{#IA|hH4(ceV%J3MnuuK!v1=xB zO~kH=*fkNm9tl<=Syue_ushCKVyAS+M8d|>cuR!2Po|UN_&9P9-y=bDD44Cdw|j& zptJ`l?Ey-Ah-?o~+5?pK0HwVN=^Iv)61TIoFUweAWSLk5|l$nSu6Om;i zvP?vliO4b$StcUOL}ZzWEEADs7DzG?StcUOL}ZyGlFVLvWSMKVM{+t|Zqe17R+Oal zL?E9CWD|j0B9KW0@`ykd5y&9|8AKp|2xPAS^M_UpV^to!IEJi^$B?r&?D0ji&8Ap? zGJ6G*Z>SzCko2KxMKaF3X)fIXXC44&9sp+^0B0TmXC44&9sp+^0B0TmXC44& z9sp+^f@R%buhm-CJND6CR!H`UdG+G1#>jcMR%4Xw-#q<#jgi+sK)L<_%JmOWu77}X z{R5QiAD~?S0Ok4zDAzwgx&8sl^$(HPKR~(u0m}6cP_BOyQu;M1k&|sM#VRAqj9+YK zb7?PW@z)GMmWff8iO4b$StcUOL}ZzWEEADsBC3U29%-!rD#AY8c>Qx zWYK_9G@uj>C`C_5qWj2OG}anf=Ke!_TVYF{&Q8}G6=I^Dw52& z?`C&~v;+Q%0ze`$LK+cBA_6HyAb|*^4}s(%kU9hshd|m2psy$}BvOVz!VpN;2zC`k zo=acUtsJ^){^HGcgITK$%7t;BK@H`?2q@2h0Oi66C>KURxiA9Cg%MCLjDT`s1e6OS zpj;RM<-&-_3nQRh7y;$N2q+iE2}#>mtgTh4SX;Ed%Q3BUy@18vLI7DCMp+voYeQsh zh^!5fwIQ-LMAnAL+7MY=fus#lyjUS?E28{U)imBa8UO^<8 zh%6J4Wg@anM3#xjG7(uOBFjW%nTRYCk!2Q0G7(uOBFjW%nIn?StXFEMELcO7>)><| zQJL9mh$J)K#B8o@DAz$ixefx#br4XlgMe}!1eEI_pj-z5o?opM~aaSYD!~&T7dO!cHw6Yn?#3;)|WSNL86Om;i zvP?vliO4b$StcUOERbX(vP?vliO4cXB$;jGHQYN63IU&hxQsSnZ zHlb4OQCy6RTae>3kh>Jc#Z}*Pp0!8uMW)?K{^(R$q`!ze)w5jb)_vcme*WM~xv^1J zCvB=d`uAh)(XXC8c; z(u%0t!orHYaX*G=@5X%*Z4>px{qsg1>McIhdf}VJ-RAzy;(A;6+tHtDfBQ_dqW-AI z&-7Mu(~9wCWKG1=&8_-Kw3{X4x_bLaj5kZ_PvZgK`kVfE>g8=)f-maP{7?1Gqjb*>Q?fG=PyuG`e z>Q=_fsX9cvW}45)`$}GGjf-|VL|g>Y-?WPgvC&u?mhqy3VELd6H86eY(VzB{Xp0$7 zq6Pa&nR_{&l&PIqGL@6F@{fM)`Fo#u`*UBI)j$6Hn_HUBCmB`b zXp(XHZA>-0@BS0fCBkha?%XS4;+B(wxL%i!qa}&RF)^;yrPgZV(qD>+WooZ)+IuGV zUmGK;*Qasb9~n)`UI5P8bUG~nnn-V7s72M$$z6=Z&fN6+{NAt2UY9j$N6zSVjXu4u z@@jg0>)*=FWyf%#*zdDWCd<=4-UEf75i`u6frDVHLA@R8xtLiQ% z&MJy=RuR#dSI6;*-m-7U#5i}T2Q_h4QH--{B=V3uuO0IGG@35IpWfwk5_E6xBuFP= zI`O&pwi`YhdOXgCe8TnzssxY!H*)OB?>alb*=Ev*TBW!v;#H?Te#Ufs6IWbLpR>zJ zdS1q)=OsyJOQLM#z8w>jHkZe#NqSz!r00pqbMDbLj%W4yG@AB2wIt6rz$dQau5x^| zeGcyTlhr(#)g1fG#ba-HY;CH9|&z0 z=ENTh@zhZheIT|Ge;_Ev9|$6rOT-@tBFDs7JGE95e;_EvABd4iJZqthoB0I$>OGRWu zh^!USaZ&b%4Xz-&#VE@ik?xm?FgFx9KavSVU=`<6T^8 zxqStVQt4=UE3!|FvQI>Q=ZNeR(Q#3hh7GPD`@|^w9Fere7%ux)f??FRTm;Eb&l=V@UvavCx zU_<#+36@D(WQ_i7F3pO(XmTsENQ|;bM1ISNEE3UiQMQE*t{{uVD2p7Ce8m_p`&R;q z?4vG`V`CDTLfX|4D3MfnyQ*fN@jhlgJ5pk9MbVQg%096{zjH+PiRiefXsYVr3bIe^ zhJB7m+F}fs{VTyBL_WvH|Y56Ard(@CXr1@df85I`xU)5ES0G15;wgFA1~6>_|ShBKyQB`$Xh-jwnxkpohmrSsET|1=%M?<*Coq zLurdKT=uU7gAn-~k0YN=Nc=3>d}2|h6ElAGnTc#a1?AU{ZO9_AL5oD>w~WXl5giw0 zTiDC6LHI>Jm9NCXpR^hQ)$Pi;Q3BW+L0qb{4r6Wky1SnUR3< zvkNFQ63}r`nRd|N3d)RxsLaR-$ybcwvVSFz$Uf@M$f+@j?7yQrBM4Dmcro4sqT{0M1shyJR)$elHX@mcF0AqLO-pT7$JoSBoKk# zC<4htusuRC(In$e&C^&NoEIMLDUk!4;H?8+1Mww+Tr}4DYgkB^XA1e~^@K{cbDYNj2VW zrMOlHX_0Yr=4^KCNFui)i^M34MC7-O$RZIP7iC-6;0m%xjIziP$ybcwvVSEQgh=Gr zm_+uPRc9n_x@M7a*W65GyOl1#Q*1*Pi49sLBEMxs7K!M%DBHpYSCBF}%zEm0)tZIe|Ij5^Kb1PN#y=+;g_LY6rsUf6ao1y7srxFM8`$h7B;woa#4iN z=b|_v`HF#B_OAqk5HE^TV@kns1Gbzs=O?7C6f$ScZ763=h{{|Y6_tiN5)np0!Sy@ui}wVlU&Ks7rOITlA+WK>Vg zMzEJuDEGD^i^M34MC7-O$mcPLj*GG_Y;XlxBt}`}h~z8AaM`~SNMshW?2WoacH|S% zvBpJlz0gj-AC5)M1Cq$C$RaVyA`$s5BeFHhEZ2W zj*Thze%%`)zN*?j=bkDApZwN$-uN#*=U;<6lCrJH$}q~x5c$m^%I^@+!{egt1&_6Y ztPG>9Y(z2>W4P>J38bu#x|EHLN!hEnFTVdrrE|Ya%bQSS*~j z7an@hYs1c432R)CdhlJpN9CO%dP1?EF$5dBoaw6{#X$4XPwBYV{~H?9mg3!#0fAG z-RaJi!&EpnofYVwdKZ-GvisFvmp5)l7tojO%Nf6}%iGJp*VxnRvPG|-?(cOuf7u^7 zqSs|;d|iIL0p5Q3n8@8o`1?`CtP1M8y%OPWt1Ri2rSShV8mZnm% zaifx}5c}iSci(VZrf*$b{NVYy3&mXcw^ArJ%_cg7oE3_l4B7CVy_;S7 zv7{K09T!NF5!o0bYesYpQBsY^vTH<^JtBQ??!{uhm0>Iv6MR7r_INCu>%kr`$U$Ho#UeRNt)noT2$Ni0SLkxds!rV-g0BAZ5Z4N)?U$FgZeHa#MJOQSNfX}QoS z&&tS7hOBMeNNzZmv>~##0!bSp3qWLTh^`?@+VEJ`hRE7Rq#W#}WWGYVQc4zyv-<|B z0nW;$=*S6?Wfl!eCL+r$kYpmV7DSeb=o+FV6OUz?h%9qNlG&x4w+Z#~a2`FVwNHTj_neUZfDJzR%o@M4xR(3*^ zGVxd*dq61@k!1o(Er3!cplgV-OgL7`1e7u-B$+9t&9;}9mW>aESAq_uWhX>-S~Mu9 zi0rgLa*D`y5ZNiBYlxClJeHjzveOaiEcUDZ4X76M(78)u&uYfGGXizc{ zS!RJG6OpwbvP?wR5G5^mEXzb>nIn?SHu7;byUnZr{}+_SNp`%ou^>txk5-{((TIG> z0Fgx(NTLy086t~DbPZ7wjmNTRL>4_FiEd*Umj|2Z@j} zV}qW80p%2o$Wt&NYYA_&^`pNp-8Y5k8lpUzLN{^>29#6qge0?f-ZIyA=ku>KV>z&N z&nnI4%jy-7ofgNEQ$%)JAUQ>3JBXa1h^`?@PVrcFipWk!q_d%)yLq9THinY3>4L!k z^b7P!fOHj2=~rJkhEx?`{xH#9cjK=)LXxnllSa%RpStUn)ZoD-W(2#kGheY=Ejyc6 zvh<2HFJ>o0xddW^%CNh8wKlCAxdcL#f5yfri6|O$hJCZ5yaYn$a|r~LOW=f*VdFl0 zo*2i+?P+416`4&La7AQo#j!NXh^(zZnivsT8zO5%bPZ9`hR3otMAkMUEp|;xTuN_e zE-Qs*QNlD^;fF%A6QY!f$FfX7DHD-p0!l4_QYN5lh_XyLR>}mFGAAUNO-kHZ*krC2 zo6Xa&*P&@bjbqL{UjbQW(V%1^vdjWWCL(J=WSNMrAxbjwSn*9!m%u~=;3~pWVSJkijOAq zf6M*d%2Krb=K=L9H6`c?k)0L|>eGtIP75Tbh-?Ruog%u1DA~bd*(o9?=!oRBm!tXW z&NrtV%?XOdUMX|>E~^u(Uf*V!fKnzR%LJ5K0HsVo*AQiyaIBOGC}mDaGTT6f;g+D| z_Ana7UKgb+IAfvyfRvdhM3z}JD4B>Xvp|xG$XXCtCZcPIl1x07Wg^P+p!x4vy;#jl z=W4Oqbp9lnvtqTAAKbB-7vdjWWCL(J=WSNMrAxbjwSeA*%GDoD$%v!dV znctj(HBYl+!IeH8tvJpy5#_yg^YewiZItRuFv?d65LpW%%S3byQId(rvP?vlIU-#p zxYnXIYa5HMq^;FsSsNm2L*yJRka7@_1t79EMAr}{ZFnqeLu73ulC~&Z`)l9wS6ks) z3|HLi8gFE(Q_I4&UZ)nL*8XBw+7=~i@k0DSy)0ZCHZ^ePPfr62wmXP^F;@BERwpZuX;c+bU8efHD8efF9ExcnrMvLQs@lf zH&$ZgnT``z0uCms=F+nos-8t6mu)E%==gDCZ9$xl6yt0p63<=UYfauZFwG>PM~sZY>#4cGF`^x zT)HID&fdF`J=eAI{-f%LYNXQMbiSS>?JY^#Tap~3B}scrlJ=G)?GdrPLx}8YyE5h8 z_}HO*pJ|WUvb`lqdrOk`mL%;hN!pu;wzN~9_v_bW)qBHwccf`=SiKu99rhW=+MNz3 zu}>oQNyMW@#6F4GClUK(BAZ@wGeN{TPQ;%HBK}Me@n>QrItP05#g=+Dd)$AjRIf49TSsJ*h&*73 z>=Kb>BC^c_N#}t`zjw#`TIrv&yl75J5!opsOGRX>h^%!)`cmDi*)HGu%toVEyV+Xb zsruGuGK$DX5qZQA*(f3#MP#D|lF*&QOgE0EkBh~zHb*Gm8Fu4qnah{)~`*&QOgLu7X&QW`cTdP0Yay0Km@ zRnBk4?=@sr2`E(p%Ao?3DgmWRK&cXuRUU+-GTzrp|57D3XO)0bC7@IZC{+SVl@pT6 zUajUEW3XB+XVmG+g|f02MoDE{T%OIS4dsjq$U}uuIio^U&ZvNLMn&Wq^&ljb(fO7B z<&28WNhQ9koKYbvXH-BrqXNnqbwX0vmH4;4wOTbB9m}F*wAZT1C?Xq02L^g`ZMiJR4A{!l%jP{MtoSe83TF$fcyXlA0w*9)` zhH{>TsGP?DNTs3A3bN2>2uR=Oo*%!k%tPARU)!VL{?cKsXP!#WxTJI{#j+woKzyRN<>zP z$SM(8<%p!RDT$x*+S$+M+BIt)ADE=cw{M1~X$p}wBl0jJvSviqjL4b`B+UmRX>J>o zt-LE=+M7~kHx~Yaf=9AuMAnSRnh{y^h@`nGIbW+>vgc$TrfNJ?)SkyRqHN<>zP z$SM(8Wr3vfKqQs%zE=8|XGSm<`CSu`RbnTs5|LFRvdR%jWmA%+!)tYGIS0?X+Kba< zbgZ7zXSHwRrC;z604OmrLW6`r3K2*k0_iKj{F$x0?&?;BF=Ds1 zS#bnu8~=W2X#Vuq9gs4Nh!+`17e+|d2&AeFdVscEVl7)PiSye4do7z64?wvj0`jLC zE5&k2gs5B+0p*g2$V=iuNMj!FYo&kAx}xjKIz+i7LMJ4#h{`1qqMUW(vGyz3EG_=K z%P`G{y^>AR5Lp@`4+tH>)0Zq^>O|==yhyX2`E(p%Ao?3DgmWRK&cXuRUU+-GTzrp|57D3XO)0bC7@IZ zC{+SVl@n6_{oPw_hu&8IWNC44569AhI0J_k*&`iIEt-$6(!qw_2M%k>YN^ZExU*FQkH{sGGM4^Xau6OzhatL7`LYqe@Qqt378 z^jbB~sDN@t1>_NnZ&I^t?{!xC6&+E|s2HWYD>2G9#~z4eG`g|UznoFA6P{54<%|j_ zXH-BrqXNnqbwV=QMlde$w3Ab;R7;i9_YY-YuO72XK&cW?4i%tW908?DK)E;~^5S?9 zlFGK3*~+`}i2^ofm4H$upi~JcRRT(t6OziNB<|*EDzQ$@D#tp1shpLt%~#WgtP-QF z5|LFRvPwi&Ssyzm6}zK+kr`C zFNo6T7m-yW@=zhNN<>zP$SMmYl?NiJjQ6$DKdUU7lS)KZiO4DuStTN?9FbIZCB9gW zHELEFZ(_EzUAEtvRbrHf3XxSJvPwi&SszP z$SOyqZy#fYnx&1!P|Cl)!I$11B1=PLX^1Qhk)Dy~e}1-_UgTzxn>%|2j_YXQP&$dE)H+c5CUG z-iv20miPYOe(GlIgmUrdcRzfgO{!ED^7wu4h4Q8z_NKOpjyF>Aq2Jueo7>xZ_CnKZ zyIVP)x!6|9aqkVek6kwOH~s!spBRnzzY6v{D!tT>cT@`I1^}&fc-FlBrr!(Z2F82A z1j}b(3%2`JWJPz8jrXevzM_3YZr;D$;48Ur$SZzF{k{mFwU75jD6-%Du>7T6L-O2N z+nx2j{^x!E=l%ZYC;ZPZp0!cyzZtn1_{~U%>c2vGWXE42R-WLdxOQnf60yCxuc)X} zeT(OW3AXEXd5`OOy-u({$H?z|e2$S|yV4NTKx=lq(jeHsyBflGSN%=@g1Y=RB-qcd zJs{)xwO~JEGaS#@1lu`4Od_pc<2iudb*w7-XV?HT?i6kAAJQE2SR_b<#tS#5Sty1s zUWo6hIg`ce5ii8N`QXbAiAk-!FidSZ*TvL!_{8;R%S*W#UpPk4t5BjXtKY z-6_!J#ES&S=z;)|NVbBw{F-87B#Ri=N^gsa1!b=_mH+7a!G6!G*Qb%u(#l7#NZiL- z#4O3QsNCFJR7AA3?33|bQ_GFEeAY5>`$B!5Hh!E=XfJzRE@K3C_NTv}Kdv%g*Jy3| zdpV@PmqB{H`g_?M|NhDEe8LOs0k3A?;|s#7*N0W#>Gm&Q^_DsZ|4JKOIMk1doY?bFjUZ38jJdY8V6WgSi zC6PysW)!qG&->pi^i94Ordr;-!tpYc`Y_7=0Oc?NN`r{35KuYjxmQIOT(xolF#NUf5;fWuj^>-`???JG+s471X*Me zC6S0M5|Kp~NFotgBqEC}kVKA1B3mh|L>>f&L6XQZ<`86C7_~$a*<8h=P}}$3@!E=x zme*FIBl|@|mm!NRq9hWLMIy4u0!btyi$r9R1(L`SNn|Tz^$F4RlY0q<@sr50u|tq; zVbl^yWEZmBb5Za`U8#C)zXbPUUa1=ETH{F3%NVZNyxZ$qOJvJMlq@5%Wkj}IAX!Ef zFILEwG0JDXBa+Nk^19z#38bIq5s@__vc>{QBcd3G3RxpYS>uRgE8g<5eQA?yNiT~6U<-Hox z%I2AO8A@dsWo3YJNC2fWL{tg+h%~=OXLgf~%*0z>_OAr;(g(@Prp69I=>?*e zNKBGP6zoF$B@*jXeHIgSk28_aO}i^1 zvPeYHyLe}aEE3U5l$BtVMIu^>vdA$?zTzz}`&RVqVaV`GOP+rp?NlE`+fyrXe* zb*d#t)#FTZuTE9ABC=#e)`iG}h-f9sx-iO;5v@d7@)#wTt#DP@dJvGwK1h;0#vFod z5~G$#6KwoOt#)6Yk}v*lrtC6gWkrca=6XZNQPr=Jj&a)Whc@|L4V}Np=MI@hC@0#=M zwy2zErzmNQx4i6M35N0WJUcaZ2(mPMPfH}9{oW7X@i5-|p&s@^(|IxE0Y7Hdpp? zQh$G#pTk^+qQ&^U?{DVTqm)~S{Mkk1+$xa2KR^$iTU(=?TVs?4K03ebUkQ{z{p~8Z z#>NgoP9Th0BIQ;)RurGM?}Ow0Q94>LF}0HE!*K{okr-u>fO4P!rAR~;2`EJZ@_EYs zhA#ar`-|WdC0~J-{VTyRNESIYb_hya5Vb@S+3#iZ74Y$1HXW@Om`db+vC(D7B8w=A zMC5@&WRV4uNJJKi$RZ0Qkt33?R+g$0&V#@(eiAvx9D-~Mqn1b_`+bN0dN|&9sH63w zQ;Ceb4|l8AA;=<&D2YU5k%%m^KoW__A`w|+fh2N764^>sCGsFJ43b2SF^3@A!l)&Z z$bOHgGjhB~R7Y#~i2D2!i$_(Ry8@(k{vqW+E>`7Fk3|Bq9$KB8x1LL?W_CL>5^fi5!u9#amwXuLQ&R zN#xkrA;`8cYKe3O;A6Mj6@Xq+DrIA7qYil)va%vd$`E--5LsD)qzsXjA+oXpN!f^G zrWK-UH9QCmgCu2R%pu5LFlvdEdre4Ifd9HGEF^NQK=mX#mOH8=y#iH^5m_W6D?wzD zh*qMk1fwhx(Mpsr<&04h*@{#BePg=hS%P7ZByw!*5M)~zwM0@j)OAI@R@^BSKV@ar zRw#@YqNH^y^wJA4+F8aGx1zTf9?1&T^H#$ecP>Bpqy0j!O?6%W*I3u}-|p(VKJ~BP z^_w?8{`F6P=;p_N_}6~&-pBvoogcXQ*57)^^LKyl?f>?Z7gIs%lTZDftn1SK?q{vK zVcHZiR{t2=NA54TK3pS8dyYAV$S$=OH^KMnwo&2ins$bVNb^80Quj@+qvK=`)&FfYB{JKohapj3#m!+$`E(`k= zTORXiW|Iq5Y}K!8tmyBzzOFG@wqqBkc)e<$U$=|paqVLHx?LRqx?G&M8gy@3c&BB| za??V2(40LTDXWs!h~^-UIfiJaq1HUtosQJUsfgz~>VfAvBA)B6N~UTqS42+LTp}ul ze)7I&!F#<{@?KlT@?i$@|4e&js^+rMaU;B}Oj;u{$}z_fjWo4p*QX=(aVlch)C0RF zV%JwCa+Obo&{C1HmlOPSJnh&m2=yr98Uc0#mrtP-U+=thbIl;*CAbdvmj zKi1*}RT4bbpLy{@RuaThJfxsi1v$-P+;*oXharh8qK-ovOl;6jPl{HKRU$jZZrCXz zJH0xR)1S1qUIXM*oZk?AIdZ;X)N#l;01d`_Zj;3c(aNz(l&J{a$W#QBsd!bS3lCk2 zZ$-zNE$3kLbe4myL}(RhfQzW(kVX+3w9J#Dm1C7CI#Bh{GO>r2d37Y2SxDEe;KK8Z zbd{u47)e}QcGwv`1nBDoMr;Q<{3=M&cc ztRm%85p^8WXB-=Js+|PV^9%i#RzDBMRdjm>WmX{A4_$i^`0 zIOI`;22D7^(UcQ&0jm7T|yJb*cE?QyGDw44x>NuoP#0D+%q-f<>C9+KHhGinM z%&Q~GY*ON8^_HMmtz~25)^(EkoC-!(k&G2l$03a(HfWhAMJvZDk!4~xEEADsUL8qh zlM?r0Hkq3%wrmq?hgOkn7E#9`jW9N7(I-VK$10IUV>c`skwsq}>C|?t+OnId2bxW` ztw?AU$xRV;9MX7UgVuIZv~sKxSsQl4+7MaW)seKl?B~LM(KUCi?#GJa5zaGvmm?`E zqK-ovHf%6wSLorfXysTX%GnjVk+UnHoL#SqG`lt_ao1OyU9nC}GUL9Gol1TAZv|W{ zKwr!;Vmr|HZ)i%EP6oN^G|1Oe=l9)pq(myB zjzjuzV}nkllcJSlmB=}U-EfW}a*kacNn1R*T5ejkkG1VTCF^n|H$~KONaKYKTH8s{ z%CSmhZP*QKLu74NN17VDl$Gi%%iMo>hO-(s*Gva)yLs9g9|uRid0%p&L1`0?K*ysz};CU~R3i%G$8{XBEj! z5p^8WcwvKfb5gW&tP)upcEj2bS=-f-rp{gnx7_zfrw6UV$Ht=0XEt?Sj%2KeIu25WiDrRaP9Zg*@^gs4Q;pe zV=uk*`5RyR#P9s*jbD1lAAb1kr~mkKA3FP$m)`x>dmnw<^Z)qfSeEtTyevz%y5EeR zJlb~#)K+V{S$ZnfvY|)r-`dSUx~1Kla=A=t3$avgO*M%`;;LyuyxAkg#81CPOz1oT zQIU-vEL8O@e)uepvYo`&-@VqdQ0w7dqOiZCV-{q|9v2*KdtKhR9WH3n$nf9m(Pz^s zPxe#3_;o(6EFSXrxd9jVMwYmhZLiDE*p4!2zf8*}rdvm{Ol#}wHbL#^JuY9jiRJ6c z9r}BlSpL07{P@>%Yc1}jO;GDHaro;tG5x*rM8$BiRy5m(h`&vM+BX1Q&|v)mXXdws2K z_NEdoW!Sw|e5Vb!p6GTD+i**!xu9q}v1x8iqejHzNyMg!*fbNFzABMvZN*jf*{1m@ zGQEx1^cW-4+HxySk(%jg%Po;-gS72`V>8KTdj>z-eo^atZCTuu_WJ&G1(a5?K@Sn2 z96dlO7Eqc+WVKgC`n^6zW2Jwo7msAW+fW*wqI9m^%kF$5*ZM|WG8*4eb|;-O+V|)> z2H9xQpo}8&h#|63L^fI=8NE7^(Y8ld*>xK&jwGX7k&TW~GTMvo{OzgOip$A4|M{R* z5qW9df27-GD3cQ#baDd94?mzxPC%KQh@6~PMN%1Wd8L1uoOmQB=QfndIYmij8~6D6 zs(lg0!Y=!YFW z^1}@%lMhfPA0j8;Rgpe#(fO7BW%A*XoP67mM9!5+WgkH=iCF06d367ow0%D*soaXJ zvWSvOL>?+cR*A?e3nZ0SM^YK1vC==QERH0VTai_cQM#o6+kUFre1k0s+J97g-+SvA zWOqe_a)-!cgUId>*T4V#h}+%}_rHJ=qR-*`*D zVoY{xmL|Zy&(kr;DvJg+9*C?GkyRp!7pn$+g{O#;qP{(2}#4Yj1T!$04gM8k9;z9x6mui74J~)u2@tQBrvzlFAs3mHt^}(VSFn zMOHaRNo7+K$7)%*xs=N;i&`UP>{evWMU*rn@-QN@W<=IpAi2CclIFHASJ`!IE{-J4 zTah)7QCc|1Lasd9nJz_Znr($#$01AGTDoLuMUsWp}Y*`Y>QEzZ2{$M1}JA+KsnnY@|1d2 zB%|?`SNfN;Egs3U?KYIN?G&Xn{JEPK>Pz2jtEgC#vgqpWBmqv>Xx4gQ|zZUil zw~j%%x?zJ}-2i#0iqa>YuOQ0R4I0ev5?4i1+4kZpyPm5X9?7fQHe{9K8{FB_d}fBC9NrvhwOkDq}QO`e&8Jk)(1fiof5B zlFF`RrO3;^_RBh3J<)Neve6<+MiF_$5ZNdq8!eEGULDD3jK)g;Y_vF%jBZ6X zI!5U{=S#QRtv0P*%hKW=_L;ORAWJJ6lr%&h6hwKR13k1fjEeVM9cvSkv>1(*{#jbl z2}#?EENzUEw6TyYC&>Abw34jjP|ls$pyy6N9uzF(%DEGwa_&Urx$~+>(qc4L`j>Mj z9?5g(Hk5Pc6s5VdDTxWSeK{;P)Y8njU%1)ph>k&4Su`k>h&)t?tP+t`7Dy_uj-;~f zwN-ZADvKjYA;z*LV6;=a+_?JjThe&YUli=1xs z)_mG-9oF69cmK-m_e!<-jhxM5BQ ziRW(XPuuN1kx8v1y((ZFh;}eF1KKo{M1r@@NQO9`!f<8{+bL6N2rVNN!0w?ky|W zA40G^y%KB-c4T#vH!j$E*RcfXZ~8p%L8DsHxCy0w_J^s)^TtEdyrI9iF-|o(vwSsY zmMjhZ^8OBfQE&PKZ~VTC?>VSe^#7Trn!kMhvo{{R^Yu@^^Yw51&u;zLZ@lB5-T2Hu z_*d___wiT0{OPmje&>h(%eVZaUwi)E-}}9#0|p{IsO2g#P*2T9ueCklAC`{UvsA8=>2Ysu?dQ?JtC)#vAyv)vUjK38(+`& z={>cH*xpE{RoZLgv`Q=Y?q29veL9I>6{kz+`MTWE>oP~L%NxBeYxKH4qx5?9_ZoZp zd)cDbPxbe*G`63mr`1t;6y#@YMGmI@{vN+y?f2*0iZPkZjDA9ZvW|#zi->cJNW50G z=DB2BjPrzIoLfXr8{^y>kE74E%1<>@jIS%VTo&r@>on5)W24-1%tbr9tV&5A-&9H} zpJPdsdZx2m&!R+ZnutvkiT7R}M`N=s#?~mtriq+3#-_*P$h68&>vtp5W5hJl`(vX# zee=bg?{KN;y|>+%>PKID@tTXr&;Q-+Xe-J|BR`+~EMZv@am(6;O%= zlx6{?T0l7(fL5Z?6hx(7Kqrq%#nZ8LWuO(I+VKQ*0i_MDm&*8#x0^V3$ZO?@bUNOm z;ttd5JE6jR&8Uoy^~O77qlj!2k&Pm<8$>pWXeG*SFv>;|ojl4$$79K8D?*jg5$p!n zMiJTQh-9?c%5VAOH+VJK3qz$czQ@l#Bn??5BCAAXm53|_kyRpEiLw-ovPwiJkFv_~ zSW+2dxcaU%1CKE~C002ieP#Zrw<+t@p^_G@&!lCQVudUXqbv=PhaXYASRYHv2u6AM z5m_3dlSf(Fcq~baF^re$u1ne&BWZoy_usJcFO{?z6|RFRyw``yXuSWK(T1Fch-?&* zjUuv9M1JpxR-)_%qvFNteVshYM#pZ*Xe&Zp>4c1qF*_wr!x717FD&&HwW6?8D&vag zOl7;wX_bhq5|LFRvJ^yCiD)ItQZULY5uH5BD#v3Q>sq9yt zeO-3E@~mU^>e1yb%?(Io{NK*-5TP%{03;ECWFU}01Z9LYV1(2mIAMg8jmOYk;qCH$ zdFWz5Bx`JF_d9^EGsEuy`Uv!jQKjBkxwu2oVuhS~80FMM+MzW9h@)N>G)0BiIdYe$$xWde)RU^~NZj%*@M1Jzvh3zV_3NrQO3l=(wnyFQ*12Ee2}!U3tEoV&t=ryG~}tcO_|m?laKm z^rM(B%Ed_Ura%QZl+pmDG(ag0P)Y-oZx7H)R7!)W9DYD2k4kCNu_P_VaP?hT+7vU4 zyQNJ?i(FF@x7@We&?5>&m7DPk=uC6_S=5>lSu-MQMr2`#JRpcxqAU!ftQpbCqpW#6 zmNd7rQcb8Mkjpl>`9&YcU7E)zY3{#N`_j$$OSPJOdCJ(Bj%O+xvPwi&iO4DuSqdVn zM6?oRDHvsyh)y15mE*CbGRAQAT`6P7n4OY%aSuslSK_ms^|DbV=UBy|mfMX8Hj2nb z5!omryFujlj%X#yZZOJ55uH5BM#p2xXe%~Va*kj(xK2()Haa31ZA#+XN1KDkdqZ@r zUQDX*6H{vLfJ7pYMg)?GKr#?WAc8VN8ZbiY5S%bV%En{t%1_Hf7XuQF%o>uaW+Q$> zZmPz6b!5No_vbU8v37BXa!H(@2KOk>^?-6o1myfFj#W0e66O5C26IWoZs>INgi*O9 zPRG&*z7?UayerM4*a?Z8Bb~0!%Srw1Z~9^`Y3-&fOWWUOsv%F6h%60}haZurN<=GB zmWEN5hUnx`mNp(s(qf=i-<70|F~hh^+K7~Y{XRG^e&c;`it9C_UiGHW&JN|O2PjuP zK)LDxvXr=`cy`Xzx{<3Mpp_^~!3J~H!v_688$icJ<*GL|D3$TPR^OFWV%MF2BU1j2 z`vc}Z9P|8Zw=P;*{K0IDvw$%UiOA9rdH50e?IBu;vNVkH@FO~Tl%WYes#ZGkufTA* zs`c5F(mJ2>ETEG|S>@Obsf=!{zAMkO(7&8#CnS~aT}8QYn`et6Q>lzvh}mfL0*Mk? zB}Q2#B1=K!H;rf|%2F`ODiNJL$|}cWNo6ZdRaTBbDU{`+6|%}PN-8s|sT^xo)MVQv zFwU2v$+4nwhn$s&tP+t`BC-@jR*7gO%2F`ODiNJL$|}cWNo6ZdmC6z91~;#{O{up- zRyjuL+sC}fRMO&o%<`{qR9B)r!GS1C!v;P4h%61!N>u!P)p<)R8a#27rH#juv{srb zX(N!CUfJan92h}M8>1v`C^C&iYd*y76{_Ygf%h-nCGh{9D!;z{{O{d(@V?hS{XP|$e*CNN`puhf zfA!Zsc>al3f8&EUf9&fozi{(wKl9_y-}=Zav1#B~`t{>~qumreFEZ6F*D5lNwIfC_ zcAndPJSU<7BoaTUmqg|{<`_~Rr|w%tLRrx_&#Tnyns~ycn3_1LDP~Eg;;-AS#9=A| z7P%efUek*kRGh1u-fv&XoC?D1blg#MA{reccFIKNPDf;cTdVt45j&;UY;GH|If|)? z%~8yfWJy@e1kGtx5+;9Ru~L?V#q5Hw%N_lFlvteT^{V}d1e_y>W z6ZH4{ugj0-`LNmi?3c+ai&S>x{EFz=oN1y@DiP-w6R(!b*5t2httQGZK2F`Yia5Wh zHRsnh;w++=n#5lh?`KKm&t+kLFv3(8CZdNxJbB-<>AgN@&L4N6S@fBU`)_zOs*3J^ zT@P^3|8877`rQv-+&aJCP72!2b>DI8{7t=uH?>(;Wn%tRP+61mR#yq8QxScV6yr|? z6B#}o(Wio2tNT_Fe=4Xoe=4>SyQi3%*geH8iQLfvrOCR*9pmN6=s>XfHG|nIc-mfoVM7Y z({>xmw1ucl+ifUw6QWj7<|affk-mSAHD=Zpt?$mTq^%V+SsNm2E0DCE6iHjrptNm8 z)`n5mwiU(SSKU}ac2gY77ds~@%E2Zjde|n^vC2&FMa{Oa?Khvjn6vGRo^4w-lTD9% zb;)!`8b?GnT_Bl0DU#`;!Id7C$fmJDo8F476Qfp;O=Hv&NoJE04K~wQeP)@rhI5AWt$ zU0m%zU*rKuSpjs(0VB49@)%{W3Lr_?D=FFrY}B{M_+e<4nH_mN0HsVsmU%*Cnb_b;4@;CXp}~~74W%)NT0vCRp=2m3U7-i90k!@ns3bIX%S|Xk6 zj2jqQ+qfPpbz9j}v>1be$l3}dZ6`(2Ry4TM!xC8=HfU{Ik=TMP|mp!6&IF6K@oKv@|=sulDOhnCCWJ$8q7I&8?rd; z#tO1Hj9MZ&ZBn8{tvMa5(WIr-KUwDZB+UV7JP=uCfn@BYNHU8CS9(|?%ftpPb1Skj zj9Ni9hEYo-nax++Ow(jyy;?4k^EJ5@r8zUPq$(Flh;n8YQS!VUd6C3p$ym`~iE@#I z26K_zhHMPGv4U(2qn1dEDQ{GEHiFLoXPA+LxspP3nZB*MH>5}!Id7C$TG1(8{3L( z45L<%jbYRhX+r&!cUo(eP&uJaKb^>EM|$UDDe4a8gbGn!2a71rwOgY+p<>iZ4@+ci z*u$Jq;aDrkZm_`>WH%VKM9RS~C$TFjnYW5Lmp6eTuLclqj~DY3oSu9FesZ$mcfDpwn}E zl(ZF5D?KccwPAzSwiVe8My(*b!KfvYwsGr2Ya2_TbOO@~)^h5c|CZBGPMwIP4I9i^ zb!(KTPK=V9;#eihsS_H^sdF2$8|=mkvKx$AB551z(lXoTmkC?xl(miZQ8L<4W*bCh zN}Uin+ps~W)Hama22q)9+fb$yM6IAqDTrDkt$1zZ<05POS~%`h_Se4UuU zjd7n<{FIf|Xwgo4;oelhmKANifGr%yU&Qay%X+n;JncWZv^?!E#`3iP`No3}zy9eD zt32)JKljdmar5o};nN>F`^c+b`QW{eeeUJ=oPGS&-}&IfpZd}teBtI)p7z93pUv{L zJAYGGRlN)1S=-C-W_0n9ctv{;-t0Hr-)uMByL3(Wqdk28_HMnv_Xu~e(9Tt{SmQ0BKF5bBf&)en8+Uy`y*n1MC^}<{V}O} zZGYQh?2lsXZzS^PGB%e%)n#n*7uDah`BMvr{SmQ0BKF5bBf&)en0SGwzE!=pKWfeX zh}a*Ks@Hj=#jVv1%}WXsppnlpN!+EE$n4BeG^h_FSN{ z^ESOT%C0fWvPYyd{9dx=AAZJ?HGONF7pKW-^lIj`*Fh;|5!opsJ1vlgr9g68AUQ>3 zr-bSVq|?9?MQgB&WTYaJ4v1GUNTtWVYfoCnzGzL}Zx-(mN}V z#-l)ziO4b$Stg=*v2IXbWiTo)7U4jZonMKvOgxrljz}_7O8W}_(JW+|m#j%q?9*&ZU>E0F9JNCQwH*+XP|h-?p$?IE(g0+pTjyt*~Y_Atu! zMx<11Qlf0FrD9QrCYe|al_x&a*|yG!T7{ZrVw7bTNaIl;$t;j$BC)eESk^gQlwtagX6HFL%XhQi(t!5lCYJR38S%Scy=n z8AM1Q0?9)lbqFM`07Z9|f!iV^3?rm#1iRuipUDDNxA4WO-Mx^xXBkvm{isCe%*35s; zYIRU~S_8;3<5!4X+G?8ZC2i7DAdN?XR-$}b1IMyVY|t_hStcUOEKu2b%iJ1enHXi6 zBa+NEyis4#7RI7D%`)e=EVVkQlnE$h0&-@?4^K1CtwCQAD3E1hlw|@+nSfFzpp*$H zWg@anL}lmm92KHcCZLo#A<1lG7`HeznMV|-Np$?iH}lep(<~a1MI*B40%@!Yq_HZH zL?g0jL>7(6q7hkify&N1Yqv&OG)7tUh$Om8S=l@-e`lFbaIHGZGNVRn=D8uuL}Zx- z(s&d|G7BV`h%6J4Wg@anM3z~gvh$X?HOewE$}&f!Go%>#oPgI0)|8&(PF-3aTWyq$ zBC=6LHd-JJN`YjwKr)KRMiJR4A{#|yqXjBEZ=+kIY!stxbVN$eE@ib~O)|%GWyx%{ zQI?6wG7(v3fixZklFR~0CL+s3WSNL86Om;WsO-FDZjG``jIzuTNoFrtv&^Dk%?szW z7)xd=Sj&YIP%fN+a^Xaz@xT(T)hI8V&|of{fO6pklnW=ITsRSxozI1HTU0Kb5S0t( zgme{eUa)3u^L4QA=jC5A0i`xTDFBh*IU=9$AhI?!fnFW$eM3#xjG7(uOBFijL*?G&{ z8fBRnWtk(A%qAsJ`<7A^tSK|2;hD@X%N!fjggP%+^MpG8JtTMaigt0YvYb#MDkoG#dgs{SYLq8bXfP*K zKsliT$_W)vPN;~=&gX=>Ey~((G;%_n8dMJUf;C_ES=(yO^Xj=com7O3pJool$FjB& zDHSi@Sj{k!2#X%mS62&vP5>VHstacr42tk!1GG)qJ9go2ywS7E@U!BFjW%nFZ22E0AOs zNHP&wCL+s3WSNL8vp{9%Epuy>Wnz?Nj!3KHyo^n&?C(q@qSC%6?%!#V z5A_0phuSeUd~p%9k!d#^^=ZEIjaqr84djjXx6kxesI=hrQ#WJRluI_n(IMX4y6|i? z`7p&XB!p7l5VCR%@H#vL96`!)`~6KuRGQ?Ngm-d~Hx=hCb7 zxb;te({Cm7-;B4C>2Kl**}I!weEjjI7lpr~r`z9*{bRJ3$SZkM=qql!-&)~o&Eu^V zitIntyXWIi^*Zo#XYKJ6@AW_L^FQzRKR?lhE;*_oXg#hP;MH2 zgwo;rj~WgipTZ~DE|NzQwzkKM#?V?bWq&7HS6jEg0 zE-~db;}2RiXrm&pTVhYP$A1|Me z-a5`l|K!GlpMU++Kd-Xd_x|xazj*WS|ATk`$lXu9<6WP*`020z_dkC2nV0_6dv1N? zZD0Pvu zHk0`Iw}^?=tsu)7&Bt0VVz0ZgcZ5wWXn#6~ElCN@GbrzRSAKF)UH zai`YoipVnN{E0hZA*Sq>X?3xz`ZxZdEPF1N)jUPS_O=l_qL`Z45yhOERAb;w;~rEo zHbF7AM`RhJnbK#u7tP=PS+2MXXdi8y3uu+E<4%t1a};0huDHsz%VOo4h+S_ZHcBxy zu~CXSHL1qHuDJ(QjJ;8eT@zWx$fYdASM=s`DvK4-7mX(tUo`X+NBg3YOXd7RQ?G{g zS7SiASZ+hs7X!8X0PVW5f-DfDt^r9^ajavIZDE7CaKdOTqjXOGet(^r*S)G!`&Wv4 z-K*>&vc0XyiZE&gSrJBE11d)&3{<1Bg6si1Z+nQAQTl?`r7XX)D+h+17x#bS>?M|# zIsL@Bi%OZ>P#S}%6_myx>Kc%Z;jxZEsRbHLnShp2lG%Ov8~J#NMOuda7-KAFb6u}{ zl}tpIxfR(MMy((l!>DUOGFBYx7-TKjpk*RjMoDJBB<9S7OJZ``E{Ww=mATVi4J)VP zeb2inJKc(+|5bx4$l~x=*MKChIMy-9cCbM^MYN2P(?hCZ)yMr!u3F(`UL=@%Zpbn* zY6aOCMy((l!>DUOEt?{eW zqMy(*{9!6aQQi2u7ItDqxutBFKqGgo6k9^hp75DO0^(HjTPInrz zHbmC871<3&tsuL>sB1vwXm~a)x=}`107h9GqGgn{{kgTZN=j$j{v+cXN^SFd${UJ< zHQC!xx`C(_lx`sE8juCxv5rA002)kffR<63SNq8O2fiaJU)BHkX>^vKy~t9hu81sp zE3!?DT0ypnQP+UV(Qtki-6*5145M;dg^^!INp$}a+rJZ!KVs_?xA+-Ey5Bqh+`V|OJC!G6TI^pj{$`V;Hr9Yz(8W0ZC?YtYeV1V1t&4Xc?tNGH$n* zwqa+*C?%Rrp8ZaI&#QoPUfqW529LFZ>;|K*0m)5qtYeS`V1qfY;u!iwbwbL)F2z?^ z$MOitob9(bKT&PSIk^8^$A)r3g{T!|W7yydvN4Rh1|(y}v5rAGHNrsUgbHXGC7Hdl z)iR69R`qc#qLk;$c45p3b-r4+s1;;mc&rs zHoS4eKs$Z?_1nF!YhLfFj*O4dmds&qMnpEY71^jWWtwFv>C! zEu*wL4y9%BH6`v&jzOCW$MizPW#L#c-YQ$;Waz=4WUZM?%O06Z%d*yN zQ)$`T#?rE1ol46-^@ZQ~t())t#P59Y*3bOjKl${%AN$Rh{`QTx{pv3~e;$=)|NF3G z|ukq{zYZHT!1n24*7i8u{T zMVtfF+A+krL9LyJ=oWcdxOZtunJiJ6ll?JqLQSU`a;`lD?T=!X<>MK%tR7F2)7bJj z$B>-EmaQE_a&B9;b{gW6GFiMEb~16s_$O>N8kBm-<46n>P@}4{B`}(xcqyW zqu1q)UY9j`U1m=4x|~ouTVIzAYUir2%Z!ToqUh|BLNeu7%>1)iP$f*xFDA+_BF--& z&MzX)FCxyOQxRtowRQ|~7Ex=bA@U~+aqsP3A(@DZxS#AZ=e?eD^G7HsKc8tOpJO4J zTt~Um%(Y9HY9iMZW7kCNnuuK!vFlS28>QBcAvQ{_orcJ$EX3F8qL6I)#0s5e_d+r+ zkn>Akmm@Ec5S42rpj;#YB$GqrFrsJ79v$vg(B&m^1W0XxJvS~y%jmV}?h^!MEJPuhW zHh3x|nJJ~o9G?@T4{9%B@{BvJyN4mq#t@a$F`%4}0p)}YC}-r8AsdSao6SDIZ8VNU zHiiwJ3Q1m3nm4mIoDKGZTzfxb0EEA$qCZLoF zC}jdlV<$st3>rKRr7>vmR7f&=L7MB7jwnd;WIO*sztukFWQ)j?Eufri0p(;1C?{J$ z79D>un^#&7k3+VJ4IYPV6B|4glIW}wY*||rr0K_%>6C0&kS3J{(9bInA&m$m5rGsU zkfjp=`N5{P1DV05t`4*^zU&9$e(|iG*Ysj0rCR)CKKmeFj+AN`K zj5-cEpRmE>kn;%}JQY%^bt%3Lv?xCFDb4iTiPp|um$rhuKtfb5kbrW51Z0`x4~DyL z*w_h?jbVewAsfR6PlY727oYhu`}o9Y4OQDa(JLL6K&>KY=6qr5n=v}a0A*$Z%FG0m z>31@e=?4uShcf-3!BZj4tMlSBpS#SzakM(8Ja<9la~D8)?gGf#qI_yLdH0$s$wp*t zCq#CG4IYQ=1{*vTQV#Zk@^ze|_)Ie6n6m-yb!jU|GBL_B5m_c8=O7}>JR!0%Z16Y~ zO;w|DDkParN(@vx5gnhPuL-r6q{&ze^GxRDNHQ_XG7(uOBFjW%nI}Xxh7BHvYz!Mb z6_U(elICjxMM;`uj^(8C+^f}AkYr+%Wg?2VTSZwWMp@LTuNHV*W)j~B-v-6?q zHEb)$(=0^gGz%!FSwK0>0?KLjWXQ($Yj}GZm}}zUSSwLBh7FzyNoMb%FZmROYBIL} zywp|`!QZlFTM0df3i`ib6HZoG*;6P%UL5 zvP?iJ6Hv+olrjOO%#)!s1`QsE(ik*&Dx^z3vqCl910OeqWuaOer@gSNON&eT@j~3t zm6dC8tvFtYwO}e#(+fA^g|F;at__81-*V}mjBg(c)&7qg4?gkwr$3=R(0=8!@A})f zUiqOv`@qHj_y_O&sf&+&_1z!1^(TM&jlXmD6TkoE-@TajWPI|eKgvDOT&SkozHdg? z9=U(B-S&O6-^f1|ubsCau%kq3_qBQ|Ueos`BIogw36neb?-t~|mBLYLu`5Cuqb)`$ z=Db~%U{c2@(J1Cl`-Vv5cKLps(G+tGxwSZs%(-~&b~88ahbD46oW74vzS1;(_sJr-wHQyH%NRSQiw#q%Km6CFZ&~6e?;t$i2V_>`_=#r$-(~vYewI*{ef#V&6#kH5({kSrH=db8r&$aj4jQdpk?|uoKU60Q# zk!zR0`AXz`ei5;2im_`Vc1^^tiP$w0xn?5QOyrt~T@$fuB6fW$V%KA9a_JH{&%T@X zQnS?kr$F}-I9Wwxt%&Ruk;NjiSwvP_Ah|7&ae8E2v}-k?kR}Jw&#L$o3G~UV&t^VQ6;tWEGml@`CY*B!r@rNuAaxX}Hgt)fk);J;wk}q;hGZ=P$nRtOh7=HfPgXq0c8Rrasnc90wQt(0?GsglnDqZ6Yyjx6L4x! zIo1o;{6in^mF66q&T^Dvy=+Y%K>*G%0M0Q0&M^SaF#ygO1j;c4%1Q*vF#ygn0M0Q0 z&asmK=h)Pga_qOgqwiRR=2XK1DqjUa+wN^A3Umn|!Jzve`Y%EQ)%>BCKUYe$P6Om;ivP?vliO4b$S!RJG zvp|wrAjw2znTRYCk!79`S?1WFWcJcDf2F|Ev|I$|H;VSsG%tdHauEcSiy)v}1OeqD z2q+gpL|z0Dc@ae9MG#Off`D=n1eA;5$xtqWQ-hM(q{M@1TV`%9O|$6uU3GUFs`$#|E{4{^D8t?(nj9MUyrx ztnzn<=`?9qohD@%Az=ul3xQ-Ikg5U{bzmzIWzAe!8dtmz>A_xY2NE+jB`r{d=A;^* zU8bb!^-fAEL{2J1PAWuBDnw2yL{6##DX9viq$-e-3Xzivk&_CMlj?-XNi{a8*)uBx z+w6%&Xt@;5Z>Z`;XkH2dR5zkZTnAU??q_RhRE6wSsNm2Lu74;tgS%ORv>9B zkhCGPHbmBj$l6YbtZi&iIoPDc#qu_xVi8)(oOEk->_upn2`FU(N|}ICCZLoFC}kqD zOhlH6$T9(?Oh73UP|7?RN|{rGlG&uh?SD-s7NJ?@xCNJF_98UNL}ZzWEEADsBCYWnZ++%;_S(WcDI7XC|P`OhB2LfHE@y zWo81(%tYkOMC8mw?++S($CWlrjOOOh73UP|5_9G7(uOBFjW%nSfFzpp*$HWu6SB z%&9@i?B!;@bqdSPEE9L<^3TbLEEADsBCY*V?}JH~ReFWq?X;_IJ&QRQZT5&wIv{Lff!_SN%t3%qZN&wbH!qqRzx9*&(R zbkqD)y5uq?ahJh(FOsPYM#OtPnM`{z%DDIi)i&|1&p6NI6=S}^QeCk$fpndg>>f+WP`0A;4`tvkAcH{Fj1p5iI z8yHWR1=|T&-e@wOfT;%`yWM)wmV&s4uk~PD3e-R=FV55PSM%u)!-NwvOMG~vC^+Yr zm|5b58`Jy}6G^-f*H5PDBko|+&cD0y;B&8k`g59aKK1ImzHsyHuYUeRcR%s!Z+!6PN5B5^3pc;^iFZGL{>gXz z;fK%03FoKRH)7Pv5!24@*L_vqcq7J|Xi4FiiiEEsW{qgUqvON^u$j2Vn_8<$2riCO zllat?F{%Nk9=J|>B${Mp;gMoNkR>e$@oQzYIaW*_LVQXvoeEDe6|p5c&KmIv%knt( z$E{U8$Qu1+Yc;VYI!;aOk7MMINY>d;eUm>g*shYB7HloMcU#@DW(@lqD<6*&6O%1)75lMPOdH^9uJDC zNE%tjtP#7SnEE&sNh8bFYLZ5lF*Qk76k}IQlCCDA8sm%DYi7M3^|94@)cbFEG|o}) ze_f~W`rnOX8qsHh zW9s8n#OA5Bn%F$W)Wn|&j?rg=h(8k}nZBg#Z{B$K4Yy_b*2TpSPG3^;n~T!^+4ijP zUZ*OD@&0yaN4ofPIno#wQ6;iRj9NudND1hOBN-7X*2Gw8Ip6Mo_FQvqF4ji#`M(}L`eC_W!->L2{=Idc&{R|%tlR-5^xYT>_+_k zD2gnz4NeeQW}_yE1RO*u0WBf{dnB1v%JDBbnlft@D^q6sS^6}@TxFw%2xEPz(RQpU zVm39$inkl%F=%jzFlM7B2xB&Cf^s%Bo#)A^w!xfDTa;uLU-@X-{+Bn6Vy$A8%=l$_ zmzmci4b>oOh%jcOrbsdeQIlg05yotT6NE7vH9^Q6L@6^ZLS~O7vq%Y4rrG5J*PMd) zqHBRGQ?NxE#=ZhmpPU*sMA)=ZQ^XW(xK2KuxX$Sll!kCSkBFP*?O^!807_$vd5XNlO z1R-+}#di}khRE!rB(n@}d|@dv&0Px;mpcH)h}}J~b3M|C4Wfn!aW-m-gwsKkoLYp_9?9wT#j+%5+)%MgwOTC8>61koszKBcVa!HNkz@{{CdV2g zjM)Y!2xB&Cf{;0gl1z(`*(1p;Qev$w32KXFkvZ70Q8EWeL$!)B{q~=Zy3gbASh2W_ zj>XbxyD>qTex@4}l<8-pCMYx0Mv<8YWo9;{Z=`*(Y@YG7pHMZ|)?!)Ccr22(zP6!@ z8X~mWs40@RLDb|}LxdaK-~{2uMokdf22s*x5!!mB9K60*7MbyU^xcG7i)AHqfHYLA z2xB&CiX?LoHS7jCXrnN;iZEuQCJ32>D9N-4nLU!sdhaCnJofiaDv@eMtc=Cog*%zo zBaPx9YKSmqqoznQ2T_w_4G}VJgA;@?8#O`597IW`Mab-tWY%I?PG1K*Hj;k(GvZn- zJ3$(%!@YTG)DUI*nPW{6nQ5aY#~Px{Ow-^5W%`+@3Chg0QDmk;nVAh~b^PVqW7n zkU5BwOpB1&Bcq9(^0BHY*pCkQt-YJ$)< zh>|vo(AFd6V6DoaXr!;oP@>h^S0>c`iR$%8LuL0J%n8*r7`+=sO%W5SZE)BPPE>6V zF}8{@W}_x3CsfmUuCv+(b3$!Vl3B}QnNSD2FlvC~n7hoZWw8^aQ5-}K5youP6iMbF zYI3Y0LZ)qSf-q*ICMf=X)cO3W%ZxlS`(sIFt%ahPs;`AoX4b71W$bX*-RqHtY7jL< z7_(7R#Ok=eEKzz$`VAT!b^~KJYJxCkqb3NMgD6%<^S*L*Y)GqPSEUyHjJpfs&$3D_ zx=?O6xXPlm_{vgVxc{gYrQIuqvoUWz`hT-ZEvsPvUA~ZIYhS)|l~rng>Dnr_xA#?Q ze|+Q7ue|P=Us09X2S5Av_uu-NpZM^*Zhzv%fA*1^Klr8Z{_xEo`=Q@@&+X^m^0}{c zyD&ce+P}zM7@zuE`gMfD*es9LJ@u-F;u8`zQQNGOB;IO#7(G!cU5en+wKgVXtc$_1 zC;I4Kio_4&gVtgb&jA_79Fwe8d&M=?YWV9*z2#4JQuy;k{w_tZl$s>j?B5&M}L3#dRDSsl`Oof#;%Gn zJ<#ISQMRNc)rk;k4Wk1s{YR@d5?kjE~DJoZF+ zd?`Zaxz@;IN5(P7MA<3}@no3dzU{%dQptwhe&0Wx{gnj*-5v)k-$Le9tP`a2oYdTgJ+1DVoCR0LkeE5#nrv6NFP6h0_6& z(+eUL*amTG(In~^$!RSVoK&)9g6?IOnzT$%i%8Imq0BKIAeFR`_0*`WRnGh z>Nc`zC4v+%5oD7A!-zw$J;RRQ?aVXmlufy{Uwp#*XKZYUL>nOG#RU=BWE-3y63s@D zXal4~yC5PjY=b14MU$vwq&02$%t09|l~2r?F)F*S4z(!CJ=L}%$s9zTgD_?roFHV{ zC}a+hWL^+4_O?M}S~Q6|Mw&JMl*;>3>u?gPlFSPt z#@;rFOp7K_$4D}Zlo*YYwX>QuoW`@TcL`d`pYm+TA~_vIor4f(8=N4V+9;e3kepr+ zp};nXQ;Q~1$4E|VP1^C3kCla`#tE5yRq=+9IY2UYL4+~e-~=JlMj>;6B=drZv9}E( z)1pb#F_O&dH*n0%+%KJ_PAoIiBGT_-DAUh0I6;}2CMq-2A~N%0C=JM!()L!WBuX_C z+WKcAYw9crZ38527eu(R4Nef+Y!uoCNZKxl7-ica+ANwx9V1Phwfc-JJ+}HR*THtF zS2A-~z0z#e7O@Uq3^De$^SKV1V@;xtkz}Tna-wRh z&oVRj6Kbg=%gnTh%)A)N^fL`kP-doy%FMKg%)A)NL^2I#W*Rh!I!2mMP4yY}`is;` z#ky&RF3=5->|GF{$Tm1Z*t1dC8z9-cAY!m>gV?iZ5_OD}ibYD?tyfafR-YlWFLaX3 zQkX_64v=JC5Mj(VI6=s?QOF!1$-E$9>}`X{v}h7_j3l$}Ji`Tme&-n(%bjQ9o%MD4 z8_E;a{Wt#-6$`>R>KueI+l>iArj0`807>Qr5o2!~M5aZPsAD9VwNP+Ula^;Z?Kd>Z ztmRMq4q}mv9Txf4sB;j;Y=aYoOdEyF0g}uMBF5e}h)jznQO8I!%kajGU8Y%Ela_1d zeo3yo0KI988Qypx zSdnRK(sIq*&!wf}E7wemWNc7sbq>OqZE%8e%``pCHPa&2%!?t$-gZ9MOmnPB)G^ZO zYt*ED%U6H%)hlDWlz4aLhViZ@P5a}-(OOMf7OB;mwD`$8{vxXQj%(7Q1noQD@aveDVn%h77Yw!Kl+yCnO{`kW;Kl%GV^1%n6 z`aj?O>$hI~!LPjR2jBXOZ@$&`NqhRWKk6&cbpQ3O=;6yA-YWNB->UcC-zxh8sVeP$ zS=*!bX`4Z^D(!yx$xf@&y*wVH0nrPb|1vd$fyUm zhVSR*kdXYYZ#>V-5oU#+$lNJ=sNpX@qdR}9O2eNc_;Un*j^NJ|jh-j0*2jIzZGVpm zmRxHyGU@^TTx$>0Yxc&R3^6U6@*>Qg_H*2r^c4Wo1BPZ^48S$^{ZrrUKQ=xK0q4p0g`fyVsu6bwKi&sh7ID~Hi&qOFh4*%7{d%N?)xZx^D4fg!ScX$zl>1f09^7(PYM3x>_dz4CEyA=#n6?Ph1Ee7yAepuZ(-xs~9SysI zY1<&CEyDBw$+Sh7?vcKheH3k_S_y53wQLnBK`la?MQF1KZ3Cq78X#%22yGUjZ5<7} zfi~M9+AKob07;ugXzP)*_4}lu?eM`(rDQ3;nzRQuDMt%Jn~g%7MQ9r!jn@E4n?-1| z2yN?V*bTJV2GM2_+6GA4EJ9n4Gm%f>)b+)Uqh-1KOfGkQbr}iRKhi;2 zR;tTLK#NE~i%39=NWcNoCx3vHfEJN}7LkDKXxI%B&^AZ{T0{a4kP^@$60k>-S@%cd zlJYO5%)aJ~%xhN(w$d^(&j2#d05Z=2GS32iVlCJ|dt~bsnP(!%yfrX5IGJafBJ&I& z^DNjub7gxUWL}HdYR)(ZozuA^w($6+X`dLK!4G^a-=6#WVeU#Gg`rXlx89$BeWR}7*WLktwi;!s%G6zVb zI6$1tm>we2Mj>+@4ZDF%+aNM6LgoN*GGpEsGW#gW%xb@~FxuVGkQwz&JDH{E37Hll z(;{SAgv;^JzgUGZ9nFA!579q1ox(x8Sn`K9We{r*1 zdn+|e*z334lD&#_5yv9zS%f`{us1*&wgHkoi?C-A_SVs`8`!fAV$UM%4Up_vguNcg z-p6khZKYZ%lWt!zQ<1dw`)| z8k9M>iiX|D95fAP4jPm>Xc0MRQ08Dm%E7+63~jdj32hdk%_6i}gth_Fcny%WS%fx= z(6)|--9Vdd5N#HrZGfcBBDD2LIaqf*QWxnHC|_B4n$Q&TavA4NS5*@WLktw zi;!s%G6zVbI6#tV5i%`8<~ka71DUo#WLkvG0g_CM;>81`9Q+h@pQVH>b8vr2p(35s z*bU4w2TfGwph1~~7V+V?h#WL1bI_p7!BsTuM&_VtFmuqL%t4FDL4(lNkGyiQmi%#H z{Fie=H8p2A?VmfYd9{=`k%|`K)FPZ(gwp}iU=EO+T7*-JaJr6$-N31B5T_R5bb#d4 zBAoU}srb93VyRX_+nw%LJ!z{*sb~?}EJB+_Xd57n*8oYIMQF1KZR=>*4Yb(?(Pk0a z21wd0LR*iNinY3oRJ7G)$c(qXTMkPh6FF!RGA%-;MaUc=jp6`FrbWoKC|(>zP0_F$ z$h5~orbWmcAjz}{nLSbtzW+}7j$i7M&~{i)S`pvz_jhO&gf<(6HjB_UKpL+Bk~WLb zW>LK7QPdO-yMZ=)EVNmKwgHkhi_q31xtLUtvqhQm%sr!3lo4jP0p+u#%pyOHalIaaQN2IV?v5$m8qxehj@9Q*}xu#|=6 zI=G)YD`FiqDAz%Qavd}%*FlRkUbg5;H=7Sou7jpQ+}H-EXxNQh2hFi^9W*G{L5o-i z4a#+}A!+L>#-iXO?m3MT(N-Xq6=StPEPiQ@zlg$-ED(z)NyQ6s)igIgyIuD=yZ7ji z+ool&xwc~LyIaNBH{5$iR*b#=_Fu&Ry>j?ZUo!T8-@DR{^*65Ere#@v^s=K(u|)2~ zTX=O8Kx#XeJGG?jPTA;1B`+~)74lRiPnF}`z0w9T_nwaXND6IR`luK83%v?@ztFaQ zjOc^Xwr=}>)!?>&_3F9cI;MK{Ts<~h%6#R{!+);*X=%S@NwjqP z>gsRbyZ6S@nR{1x$T-HHDZ9nq)eef%eGN7ny}+gtUg^F4^{4T; zRyAIKbX|M=rvl55c=2H`UG5(zsm9xnLi%6vBSU!~s!ojC5lcjW(OQ2K7eVzm^`aUB z+Fw)?tQTp3{eywjKz!-f-;^(r@y)Uv#C(a=-_!?Tu(I|K!VnxjSV6EpSb@lX^H{;M zV8=934&5)V?1Mw;(4EK3dx-jW5!V7YRo3_7dP6RaD*hr8vrSYvbHzlJTP42w+F9ov{jBqzn~%Qux@W$qtr9==rCxbX22$o!H=Men4*6`;D{v5&IX+-{5p%1r8l)t-IDrnT) zB7gn;p6&Jh?VjhpE^n@Vxw*vavf|oVeO;zpJM!dSmnHYQ9G&vIEc9C(-YsV!_v^XL z{o+R_nD}XT!e=3>X2-aft2S3NJx2)~`#}dDb8AJbZ2@J0z7CMA)QfP~Ect_>M0U&HxkaY2Mk+aNM8h%jawJP#q$Hi%4%kZBPz zPehVgq{MR(i_HGoh>VqMBgl*^B)i$RB4y?U5i)Iq$h;uJm~HSpgiPBYGA%-;MaVo6 zN#=D0rgB;e8gY8KA4WxTdO?I!+aOLah!AHRJP+a2Hi%P;aB2}wPegKB3s6Z=TY!qp zXn2>PrPh(z_JRnRwn1cG5Mj(VcpgHgZ4j9jA=9FGF>>WZB$;*3No3jrRLShGGHfN1 zeCcebypI7S(H=tzF9c-BHnk3<%{C=z7D$-|5_S@htg;DZ+*MPS#=Znq@J0PHaH~EB z<;6S+imU&cV{4%!Cqeu2x2%CI=SLSqq-E&aO#%5M#OPRL>}nLbXQR$Tq@`_;w6ut{ zw1~7k5lLoBDW<=aGQWh(Qs~GW8$I0(a79`$FNlz78;tifI@WmzWA<3*p=fHBY=h?^WZDLiX%R9l!q|yOGRw$ER9QJ^n<3(~ zuYr`)y6>K5kqaW6+6HlYL4-Kl;CTqAwn3a)gj0)fdLok3A|);%6{jE9v#hnO_T{R` z>>qC>nWZw41ic_arfm?J7epAd4W5UPX&XeQMaZ-WnI|I2EK;JuBD25qN5)D`YG&qs z0yckMjror=2$}|n8OoLLUK`HYjqzSbzS4CT287ZUXrdG6F5TVUBh_(wN z+}H-sLuj)NqRk?-S%kI|k#ex!Y0O;(=1yaYD|Z^>wBK-`Lz-7Fh;V8f#OVbQ;%tNG zA)ML#1jFh%=dpN0hL4-EjAlfd7aAO-h524LAh&GGRW)a#> zMAG&qs;lZYdi>(i3VEchB4wN1vM&;>F@(&1U!ScT$h;uJm~HSpgiPBYGA%-;MaVo6NoK8n zL1th5qTh&1F{orlv)fn24(ONCivUTqO-bQ}fDGBD)`7IyrXE5+hX#$UvZTUkpM-xo{B%d;@-UMW}&$&dc@uyiac#d1f(r?0IPduOW@ z`}%u-FDu2q=Jp4F@w2~hu>o_-}I9o`K||__?Q3Y3s-%4 z>c{`O&fX@z39O9_h}+@ss+Z+``uOYxT}B9 zC>^&ydL26}f3MNL{NF2kRHhd<)0XUM4>J%^;q|NQe!LIryx2eB;N>OeVI|j@8xfp#csP{X3=0rYT z!t87KXV>g&i0pXE%64v~@2;ip3EKmtAzekt8$`*tMdwAK$u>AaXkU+#_$L~r@5x0< zTqG$!-OP&7aN6&>Ca0zD38w=jr>h7%gD5$*=)5Sz*#;*Fr|VI2`b4ATv=$FeiqVi6 zB~H5pEp<=G93aVDMQ9mBNv1{TMPbY~I6=rAv4~@PG+rp z;?84>By$ioL}(dANv1{TMPbY~I6?9Eqi#&2By;^(3sI6;q{KClBC~(6k}jvjBa}3y zmf|LyMu&G!E0WUzlDJibok5hGT6A7iG&O2)f^cfPF^!U)CmJPf{l15pihFIf!iiM0 zh$bH{N|XE0VndlD$=ghe4F= zS#(|$ifn@uguV4B*?XcAncDI*`3tIBDM-PaJAw-4^TE%i%$p%@^^Tt#RZL`kMa=S4C0 zw!sNP=6aN5KG7)2EK=^&n>CmBHAHm$n72#KQon@g0g~udgt9@DL|b%T6gF*x6NKpX zD2aZeQ4&3TB&z1wQlx~;7_gnpinMYLkYuhRv<#vo)1vdDFlHN^AY`sbN#+xclFT9{ ze$OdW@T{l|r-x6wDfLSv=m5#-D#FenN=_|0FA8zC!3o0YdX$_#(I`1BQsM$qacYao za#`J<+LrpITvjb&SzSYDiTgu#lR-Te%c?==MPbYyYl3oFHQks-v8+D9D9Nk?H7P1X zrrqcenFA!5s|YQFD9NCmN;iDSc5{PN@6OPpMzZ3DqJd)HQ^F zxc6+AgY{TUs0N)Eg&TXU3Cao8bYmLDg!%-dl!IlUVteN@q1vJ{WcH7{(aBJ$Uqa>p zN#-g-%OFZJEjllXv9}FQ5Hi=JB=dR5LdzgZGA%kU z3S+jx2}0(2lw>~9D9Nm)X53ppD>cKZEqEe92S`p=5q1Vqa%$0eQHZk*P7qGlqvZ67 zMrlKkC=JYSm;Dwb%E3}&hDuumg~|bv%2kAvL6lTlbY2v`Y=aYo%JnFze4dEibpC zH09+03<|A=HCxg#Xi7Ivn}e@M$dGMn2qbPbVo+Q+ImU?*+ZI_&qWunx zp~a||7anL6M7A{33vn`(rEKx5c)So7O0q8PW_jU8yzu{INS3m7TbjM<+CsIz+X~gb z;oe7YKKi5AJ@ZG}((Ipn;oa}Q{mI|^(I0r_r{4baKXdE(pZ)BIZvDHDeeXN&ed=xR z{>|N%X8)Vt(o8pXYj?Ys#g2j6l2FepQw>zx<}7X~j}O#?hqt$xCac%tj^hiGcs$~u zwHX=pZ5E^QYxjQQtL=cq4{!q#KXosY_+SlUR*1~q4s#D`mBy%g4S#(-bXKC_?^1*j zcdg9`i)l;h?At8nuzWsa@aKAfKS%JlMzG{!R*3v*o3l8Tm{zaJUw;KHtIlNa60d8d zT|48~eSgM(zkix|_FuN#aplUrE>kP7%Z2;dB>k`vHCSM98H} z5kCE{wHZ+^t;Q&qTnxG72)X14xwJ;e7#Fib<#j8G59RIy zv4yovm-?p6KAWgaXoE7N4a$^WLz%iJY6(eZmBM{4 zc5^gj9zN}^ZjL6I7epAd4Nj3{u186xjY6hH$g~KVs|aH@Y6(eZtw-ZVG+U3BGWWNL z*3Hq7c`=m6OoLN|%+)Alny8d%P|7qYWv-z#W}=pmWY&5#t}f5&(Qq19a<;Q3O~iF` zG&#K>LY!@IisW=XN=|JQPA$TzML1nWh_g{kNKT8Cxb#;pGTY73kQo=gb~5YcXp(tB zgfZLT6iMcKlw{f{WLktwi;%gBFlM8ckYv`2b2O!2DNxJIY~N=#lwV&jhBEz3 zgHuFiu11lWCMq-2pv+8zGBejurk{ygLXufGPeW$Ed77?K_uHi5)K+8R^nwU+w!tZq z)AcAhwNW^=2&Wd|bQK}aMlB&Zt=pvWEyiw>hRo>cZniBYRmi*`!kBGviX?MAN-}K} zGA%-;Me*XO!3n~cZEy)G2m5W(&=z%4J8h+S3T+oexUmgRk+iKxNt=yAn?-1|2sf(; zH#TYsN!vSbmfd*X%YVL~|Gba?{QaBdl0m73LTQZXPH90Xy&%GyZE%XDbUjK+Z4^o^ zLa9Y4T}61aQAEGMx)ax;Fvl;|HpW~qhBX|?^zr1PGFa$3C@iuV))+&@ik zJJu92t(tC3qnK7rR8Fe~<+N&0&Yf!rWA-gCA!+*@srWCjSE`R|C^MZ)6w+&8_ zWUfa^rj0_TMaZ-WnX3q6Hfjk;X01|VUbS1FA=6e^A@hO=W46I5lFaoe$+S_(v?eJvqrd+Y6TN?Pih&=xn8?S`@-v|SLP%{Dki(zYHYZ8i#R7NN}|w5=lC*r+9> zsk7FjF?HH{v|I=G-}LMLXIxjk7{XZG6})>h>>!69sb(P*T_%xQf^%=in?Jo^H2Ev#<))YzRdX!|^C}diMOpB1YiZEuQ zmXIdYU%p+w9scI+E8Q+%HaU~M{>#e-G^<_+NR4f30tUUBMCd-d_SJ2BA97`ZOj#gL zYd|7w#K~Z*EaTgQtt=za4j+nJH#SqET@aB?w!tYI5s9{n$R-=L zgfwdwDN(;xW=&gJmNNI}Gj+Q&WL^xVG1K4_A#*hfnI{YirlgtYujM)aKNHW)>B-2JA(;{SAgv?chF&njnB(q4lF{>j?Zzu(zEyg%9RtW_lW{j^%e3%kd({hCDX#yfgN{EVY2 zw7azm?e10XA;g{DZ4V(uXxq3~J-k~Y?$?O>S0bv&h+CuzY5Ur!N}%7vhPs=>J!};J zT*Tjr7S)&MYRk`EJ^b2N+*_$5P)n*7h?}5GOYJ^&kv9f;y6(O6#(Io5UTKD|w>z`k z_P0ChqZKzc>u-h|HwX?l0ua3J!Sec>`ts_))QH=b9gC6+e7vrC@ysBJaRF1W|uef3Bbd{X_NCffsI-Z;El@ zRDW6@7sVBw{&7))!^aP)op^AK{-!<%1*hggDEiab!&!e?Po3#{f9fn)PsJF~zQ|v2 zSTrtJb~24Zo8qD`Q`Vt(6Bd)r{hVxKW-95>%};vaMw`KIZu8Usm-Exh;xFQ2TFziG z|CJZYEcn;o@Eup*cJ+1luAi{p)lXPIcJtBOo_*$R&&GuH$uIu=Z`}IvpZNR-?mhq3 zfBl6AKln%A{cAVA{MpaH=kD|W@^hcQ*-u!%a>@pcET66I!b;|1*zNODgnDw<+L*+IeeixXO;X}|r%a%u=@e4<`cCwWcQ zn%>){jX3Rp3d=H4oO&Xsp2(>qICTW4j^NZ2p0&}wwPmm4Cec9;aOzsa>7@wfTx(;3 z(>_M#PAPlkyGA>q&9YJ1>#sDm*Y{VT+UxtvV);7X0gl_}E5PyVa^{XJFDtLhmiv3T zy2R^p;o2F!E<^p_=Ly&0rj2*M_Kr?|D6Qkn>)w?%erso5hqbWnMua4!UL11!-q(my z&l9DdBcz@qq@E+Bo+nDMHA3pS7*g+2gamV~jY<6V!TVXQlGUz_e|GAwjm)=wy`R)_ z`padsmQ#&Lx{r}`N09Ca(j7s%CmN(Rf^-*y^h*)cy4J=7>3xjmTUiJ{2VX+;ad>0- zeR)^(+Lj(&L&(z0GD;Sq&>|cfloAa}iw31ei}1LH(xiz>l@~+l%rrPb>9a-YRKEHe zenFJD>&jc1itl!p$F?Z0@C=ZgT7*-JaB2}w2S`p=5l(FsPA`ZMXB(U#oc2+2dVS?9 zGpsGmMCJe~K?g`OEkdS6$g~KV109J~D79IHwl$R6OjK&S7)m#$!3j!jElS#cifd~x=!pWa zsaLID$R`T)doGfShjn}9su@lPNU1nLa%vGyEyAfqI2|B4T}3#xQ8>LILY!@If^gbL zX7#_t~d>SyR|TXTZj=GAUPc%IkgC<7U9$)oDPtjt|FY;D4bpp zArm30;4I^<9!^kB!L1t-U8Qhw$kxLmGaB9vRr$r7tQ{c9 z93aWG2$>ck(;{RJkg|3aA=5@7^MWXv8uf63klA-bGV5&&{Ah1(V^9`$rLc$h?$$dT zNYFO9Z&6%j7(|hvCMpxupiEGMGC?iUh}mvr?wY7fP>Y70&)i*$LY(d41Z9G@W6A0D zRk9>#+$gq7&{B|@=SUWDj%1O>!#0Rai;!s%G6zU9R}nI86vi%yFlHN^AY}GYl3Aq0 z&3xsq8e1icOk2i@%mI?j0g_CMkZBPzEkfo1N#-g-rj0`81rf$y5ZY`M+AfH2V;h_xwDnQS!7L{( zIoRKvr*-bS0@*y{X}_>-QLK)(B$Ru+EJCJ5(eaRJQJ(RbZeVN>#i@pgLZ*#E<^>VP zY=aYo%sxsoi?6t0sK~Unv$;B&y&Q9Ov`Awga(4M#t6*6j4ML`kLZ(HzI@$(V9oJB< zjwUKs$BQA1+4nU;xjMFPNM@~_<@D9o<73#7;}OTNarEB$)#wnHC|_B4k>G%mI?j zRfJ3%6)%n&JP%>a9&3V-*+*%0G_|v`7wv5BwwhD98vxz@W+Qa}n*p+Bft*<&V*_x! z)l{hg))Z?aIQ(E&sfCihee#v z7(}8SK2xaP*FJ2JM6*$O(qfOL9JEm++5inZPok|x$S<@n94v>aw6(Q3`VeEnkW46HwLS`Q&nMF#J?v?YIzFck(;{RJkYuhRWZEcXUJzl-HaJ1Z?4wTEwJ}%B zb_;@3mZDlz+FDWaa)4NLOoO>%8iY#QV6K=ZDp$-klq;r*%4P9l2w(PnO;E0wtsBbA zA|;m5GRb~X#Xve?-4(T|WFZD6i`rtq%M14()snRs=6E3nAggfim9pG;^z);?$fC9^ zy8V-UA*mZ%2i*~_Iv-;qzYFz*Q;pi*26oI%nu&k+0M=zd1^@5 z0nx><&*2K8*xL6%6V0UvA5GU9A0$VjEPK#{b7ID=@po*#fKT4`h%>6(9s&u@_b=gRu{jb-0X6+huWPUv?2 zA(~4)k$+F*e~sYZ$7ob8Mey%h!=)n_bL5->=bATjs6(MsFC7BmQ$g~Zj#UjSuqVu9A4I;BYmSooAG=5N@6{q2} zf8vG`wA4Z2bb#b^faG));dBrsrx!#xwGCp&A{1D3Ueu&Poc70()2tXQ32KYea_Ma6 z#FAO-pjbLBV(GMqrE?AC(rKeuIxmKD=`;d^SUL?lFAACVSh;kzV@YP| zMjWfiw8d%2Jlu$-)IpJ%10Xn7d2@Rnf{yLDJa<&q0}Vrol`fgEB`9IxlL{ zU?yEVmKMcYTEy8(kzL*^^wpuYc?Vmv20o^Ireyi%hU`Iplm9r7LDb4_yv_Lmd zUkJp=_@-oHaE!s$lj{-kW{)9jJ=luKxTI!_$Vk7~)^C@7rCy2j8z80M04e=e5$QLG zQuLIrqcmwJvmz~w10Xn7d2@RnfR6Gq z4WguN6`^eqC2bc(XtNC>z#>N3qVu9A4Wg|-mU6I`i?Iau3)V#GhYdt@QePeWlkw zzxn9>&pz{hReJs7pZ?xozWuRZ{r&e}ec;8nf8f@qzWDAh-1_A2{niiM{`gz|*-zbS zm0quT?Vo3**H>M=`s!8p-W0_q@rlsQ3a@|t>%ZwgC>!wHthdJBEL$Jy;$Q5$Rc@!h zRmrV#BfN^j?#IIvZr_X2qixO4qOf}p?+(b_hgwx!%u_>hXGmgc8?<(N7(>pv9z?bE zptTv%8J%x!M5yX?G4UxGv_|&2m}8CfN4>f{PitnQJCzx*X7Q=E_3d6d2_GJ zntNT&-0L#tUYD=6*L|^A%&@i{d2;Q`l6zf_PI*0dH5+Xs`}EN^vdSwh9AsQyJD!A$ zbA*g@gp6~9jB|vHbA*g@gp6~9j9VqjD<3l=WSom3;~XL5P9w@VO2_;*zbYVlSjxa& zTmC&P3BzlQ#%?84F%n)K!K)*9bp)@D;MEblI)Yb6;uE&Z z{zfs@m4BtI{TJI(m;!ISUGbq)a zg!DDalz*kYcKKCbV@jn_+A}Ea8I<-6N_z&SJ%iGoL21vRw6}^-WTS>C?U|^wXHeQZ z3F+I^^}CQEGq(TU=1s{gl}5<42$>ck(;{SAgiMQ&X%R9lLgqS>u|d=jA=5@7(;{S^ zh$M5k4ZMt%aw23#QPECjLCCZSnHC|_B4k>GOpB0d5i%`8<~owGLDUc-(?%iFB4nP3 zG_S_qWcp9P3mNwMXH!UQsVTyqMcA_ldlq5OBJ5d&J&Uks5%$)R6b+(=2zxdPdlq5u zM5I)_eiyP##dba_nWd&EQ_-MIMT0UG4a!tBC{xj(Ohtn-6%EQ%Tt(!YjT)j%MH7{& zXi%o&Nk}q_l&CZt7kVMH-|$K@OHC0nEkdS6$g~KV79rCjWLktwi;%gFWNZ*MM98#J z$g~KVCnCu#QsQ^;B6C*gh0}QQ+itBaHAOhJ2&Wd|)FPZ(gj0)fY7tH?!s$AaxIxqq z;nYUq)FPaoh~%_L$x2LH=!MMqVPhw=)D$7pB4k>GOpB0d5i%`8rbWoK2$}0h#s*PC zgiITSOpB0tB9hEnZ#F6P%B6Gv9kmpC<-ssvYdkVhb!e@IRzV(Q?Nlf z1sjx8ut7Nm8NX2#aXD z3t3LV?N@kxPD@c(PQeD{6l_pV!3O0NY*0?Y2IUlNP)@-HSP-fvtNKT8Ccoti6YD>kC*>8BI1T9rb$g~KV79rCjWLktwi;!s% zGA%;pI+C$L)DR)lMj_K8WS)r1RiRtu{=icCg|@!_NZJZQn?-1|2yGUj%_6i}gf@%N zW)a%fk=zWTh6rso3T+ml?L;JPeNk9Wo%>6%r3@;kPJ?plG$^M|gL3LLD5p+?a_TfF zr%r=%>Rd%~6B>4_R~d~ww=pQEPK$;O=G1ATj*+I$(vA2Wl&RD1H#Ea}{CCMh#KQG*Kzjppi$aU5!&M3&fOGJ5ZWw4n?-1|2yGUj%_6i}gf@%NwvOax5H&<-vr%ZX2yG`K zX)`;E$zFevXsi8_I}2pa0(rAQ)+~@S3uMd!`LaN^R)KDHYZoTBcg{U?2FQ|)kRuCZ z=p-OJze`e;f+CXYa3AD?NGgj+DvL-ei%2SqNGgj+DvL-ei%6<xNaj8{8sfT7*oCkZDo8I64+GZ4@#sLgqS>u|d=j zA=5@7(;{S^h?IkU0T|j2pPf=_jmSZZ&}I?ZEJB+_(fb%Li_m5f+AKobI+B|~)DWS~ zMxo6jw4I2g?Z@w2Z53UtXzg;`23XMyU_~>470m!vGy^0ndcP(5c5qfS16a`vU`1O4 zT83=I0I;H&2v#%$q{bfO5NK*FrFU_garpxG3%OlXUjUZtTw85*&R=Sca-B0M*Exf7 zoiixcIfKZt!)IQVZsa;=qH>)xDA&1Fq(rja$ZzAO!CdDo8a60jgJT^d$*cunT*&PA z{%QqaD({M{&7HmCLliGW_p-bziq7JNxSgk!h-J;#RwBlBVfP-r`Khb#e8ab2ee2cN z-MhX3?0Z`Q*w^3t#;yMAFXI1RIsB)u|N1gJ+1w~MMBgm`-YR8Y6btzsq*^hG-=Qdr zA)xnaky!4TlQ-7K`QgEyEV?zj?Z5Ks!Tu|Xe=deoB~04i>bcr}-heNz+w9}_{K60S z{E*XfXLo#7OM<@dMzo;WwB1^)zbUt>#%HqpO}|rt;PA=!g600k&|f0Wlhk#r&v3|J zx^bn5>z)Rzzo|DJ@Hy;nIuI;BV#nk8%OU%xl?s+0MdR{Ffv5f`s=)FiTmG8b|Hvj- zf3)F{=0_X-P5lvozv+Jj&=9o0}rZ{jID z`kVSxm*Y*q1KMPL_#A z*2eVgiW<)&+V|A876Y|R^z;|s+L*AWxE|;MFr(H+B(8Z3Vkob6#~G8Dfd{QoCEd5C zSygL7>@-+r)lmT{mim&pEFe9P=>59Z@V7>=iVz9JE@aJOi*Aw}p zfRqx&wyU7X-7x#gm?X8H-mU9t+i6hd`nIyDbp3T-d?}Z%{n6{P<^F#3x*Yny*CQg@ z)rovxJeukFb=h-&FLx`i%bWXqS?gcV^Qgn3Qk^H=FD3Uni_E-gr@>i3dLAL;j$6x& zTOpZO$1!7)8RufixD}EacN~)$w?Z=Gj$<<88j+=|veG>$|IUga;5gfW09QLGrmmmiZzf0&#d-*OALGiZY8^tE@ zv5_3w29damkY%GL2$6#*nOsG;$&Y&hLXV8YE?N8BGemO(i;qAqbg`~|kh_+RP z8yhu2Xd6UvzOui|x&DqgL};@OqHPtS%|@ZEN78ocK7W~s`}bFV?mQQfiKfBK#5I)Z zW}+r2GtoxLTX@)=b(goCNor7LqD7p`tf98bf7rX*-HETm59t%mPZ4jBO2xB&Cf{;0glFU_vOdEyFRfJ3%h0GpFW|0!dDw)~e z^Cp?4oG_=^{RN@NLQJ!!!JKB-5XKH4U{`A)br+RIInA15agJ)ECMc&_(_l`sYbd8# z6P43!Ly}qEa@^EWWZLr2l-X`Nm*;X*DRT{_ zOcRwd8^4M}FbGni@C+!?GaEO!PYF76NBIejc7r?x?yt|G+Qs0qU9AWBYG5l(FsPFE35 zZ4^#>bjtpMsI*0n=OU@J4We=t;mbx%5Gn^zQn`vyX`@iNico2zP}w7;XT9&9oAPaW zDaLG(tJ6YCYf(iNe7v!J^Nns`tP~%AO9Rb zT|O3)OxqwbR}sc+)C3`O5G9$b2$?ntnX3qyHVTs7+pP+LHAL~(jrYqIoI zan-8#$By;FEW=t|u zTnyQDOlC1;*Ktf{*BT+aj$<;rj>++HWKLrpF=bx7s`KJid18iY>0kZuRv`~6X-~6w zdhj$0Wo$mVLY7tYhySO#&t4-uyrg|9uP0b`t&ItmUCfx^(#OcZBeNL!cQJTbCGziL z@bAd+apYfq;uO7DZq&X>rmP=Q4^H@ zOw?!^BH z(K@mJ5MR1V9U7(&x0tU;`MZkHX`?0xoi=KMa5jjNX^W;&GHs(6{dFYMHVV@g9Un_F zYuzAMN^IRAGW!xW0TKea!fnb}tp<{aE^o{`K_xR!J98p2pS?`t=6mj)*YV>W7n&@zZ(4z}IUH^M;_ zbFhhGfY+m#gH3}u2OD&JEXgcmm`{c3s|f{P&>XDigJ}+aAx-yNS7JIY!tBJ;sQU%H z6uFVMQR=qyd>z7=jhY~g*{BIZ%OHvc)pULu#e!<07~sLNCQ&S?romiL4LUxSWM(;N zk!dRrk=YlyX$~$Az(eLL!kCSkAdK0l2|~*tN-`~)MoEi}Vu06?WZEcXT6BCY-Axfv z;zCDJIor50({n$emTJUI&oz`eXrd-4bI?RhP$rsbHfn;6VMX`>k6btIWK3Yiui zA4}gk`x-^G_2qCfS_}3#-`SsfU5C(S8=N5A*r*9Yz#vN6ESg41n~h>9*O9c@D70C0 zd@SW)9eGY(PkkDzY}Q)6d*uVv?q%$dM91%iyK|u$HAI=8CTfB*2~E@lWv1CE^3$Mc z6!~eQGG|s1`DvmuKMguQmPD74j~{W%0%|K4Gi%$$XUQx@jm)SqmOGi(p-fE^H9?u0 zCTfB*|7;XlYtS@`tTj=YNvnvgHBp(h1|1(uGRw$E=Zj49v}1`Y^>H|jeKB_8DpD4% zBE;FK2|}EWnjq{9qU6+~X_TDWD8_jm$*GOPsYS=flG9pbI;m$w=HZ46rF4!2T}2qP zQ4@qQ8#O^_8AM5@MbjwBv{4N3I+9Eqg-nZ%k0qH^%A}q#GjsoCwv^5xvwu>c^7%TH z>1P_8piDm#H9?tUHj2zNXc|RknyAc{RYYc*sLV`*j*q2N+kWFpw8afty9u=v$>rCM zHG~`6-~{2uMoka`22s*x(KJfhY!pMej-<^-q0OS>V<`t~qy#c6xu90K9;ogTU16^wA0UL*YuA0S+(V_GZEzZ;^s`Z<#5z*?*(lP_ zqT^#})_fC{PGxUOwDl!yGFp+ets>mms0qT2jhY|?45FmXqG^=0*(io`9Z8#wLYqa$ z$C9?bt`Kc~37fQ)y2AXfzW<(Z9m0)maDs4Sqb3LegD7dUXc{GLHj1HKN781a&}Px` zv9#FL{j&I;(C?QeW3_rsXD;DwTjx5Y99%^hvr!X-F&i~OXcX&nf=qmI>aT>wiKU&FlHN^AdK0l2|~*tN-`~)MoFfP zVu06?WZEcXT6BCY$*k3eoYdHALuB@aZ8BD>4dX)-9a%*fvr!X-F&i~OXcEwjW|d-BgZLl4;jcUxZb(|df8W<2 z{>?`}{OmJ7tX%~^`1#L${>G1g?8EQ5^`rmv6Tfry{O7;>RiFN+-}|oHAA8gHeDtcX zLHyzUu7bKF|5kJ?s^rQY`Fg;eYAtS+8aC}@#|OFJ<4&~{B5?z8IB;YfbGv-@T}@)5h ziAxIyZDkDZTnz3U!JQ+x>q+)m?)ul|j&Y_srtL5&cZcmI5`p) z`l(RzU9^1X;F8C# zqR>R)(?&7M7U9z(d|HIh9%)dbhm#}WvyYL_`i6B1V-Y?r!ly;}v}G zQqEb+{FF@5z_upp9TY?gTEq-$5y~uLAT2_fMJTffWj)gSj?Pbxgt9)Sd&BJcV8#Mv zHVS1Hq0FLq@nGDyu1i@TqZF*we>~1}QvDZSV0R)*p;@kk2IW#{P_BgrF}#Cg>1@HG zTn9sh9%N=BG*I{m5ZW5xhfij$o^Pc*I5@^j6|0H#RY~k zm-dx^O3dRLzv$D>XDKJk#55=q)1XXDgECzV%EUA%6Vsqf%!V{5(fP@dGBI0>eAYKi zVj7f*X;3DnL7A8aWnwn8b)Cd)Kt5}IAHQr*>iZ6#e^58E<5$T2gj4JLILR>xk+wmG z*P=YhF%2Se5T$9VM;eXj;p9k&>|-RdzF~>92$2>c(jr7!gvcIkU6;rnNMz|>ocWZ* z>}&fJRSWp^Rl4clfb!7-WupaBXn}^+0_n3r@+?pe_Ta0&>DAP{4gOx*`oHhqp1$Op z_bp`>NSFoEWr1Y%VC(Gu@3*==^6iwmz4ld8pTaB=iw{6p+fA*tq)%yQ5ou=;$zTy_ zXAx;<5%Xt{WFvYwITC5t$8>L4X=f2>XAx;<5ou=;Y1gBz>q@&GDDA2c(r&e8FB*#` z3L|Vk~T7*c85NQ!2 zd!*5b9!`#g$Ua84>Km3wi^xce5NQ!2Ekb0EwysNL4vehC)T7*c85ZR-x>k`=mi7fq#Gp91`USGM_??a%sSBk`#eJ#SLMfkLcQML%5 z7U9z(eD+9#5}ltM37>sT_lD)uB79nePmAzr5k7mgbzMGtpi2NBXN$U8@1>K`TL*J# zLAhYItk#>mE-DvH6U9Idj-^XF7UhCz8q5W=A-(VD;p9lUV78d;U2%Ebb|M!{6O{|5 zLAhWWlnZ7%vq(x+zMJTffWj)gSjyl1~kx%)}yWKQq}{_nxzwQ&92Ovc1J;!96oNKlz<`9qDd4Y zZ4|?65h5)@q(zAAkwzmrKRFU2`xuF=Z&=f%MdY4Eh_nci79p}nTh}GB2NGHPcV0~% zBK!Lb^p^%Bvuv_Vt)IGGZ_9Lb zd;3peEfM1@qCtp^aop)IQHZpN;kAg2vl>Cxix6oM zA}vCsMTqRt)^&;Ofkc-6#Z~em^7@*+_UpY`E&B&5E#oHVP1pwH(gK;ZK%;AcELtFk z7R3A90bOd2?hcP2dxM5_UEIFC!Ml(*8zE~J$XO4z&hGzytJkAWVYOb5`EyWE#_v%3 z%K)XykF>K! zt>@#*;9f2 zRR$7nAH9tNzgE5%)q3CghHt<6)~m0(cYU?q|In)SzW&~S_vj~|edZ^heZ%W-&rAIJ za=riKp4l=`x5ZQbb-R?Q=)19Qh`(L_y;HVXqQr>X<_}AZvdkskuq|`RQi}M#Q21df zh4{l5pQq#3B5i#4upa+mIsR+Q@t+HR`{B*PpQ}7Ka8kT(>yIzB*{aAlmZ)xSyU4KifV7*NeXl|3#-_%?0SeN@-?*z;3TzSuWe>>N93lTq=nOley zUT;g`z5qD`!6|y<-|EJJoG2d>RfEvtiP#CISuqnx&9`W z2mNQa9VrvT{WdXZF1gX>l9*@Wh4?`}XPcOT;|0wn-<5O8%i@KYQF6A4xvIQSW~{&d zhVQugwv#58?`xCGH{5&cjYpq;_L)y>cKPJ*|NIATeff)@|G?c(eb+yH$JGaa?lZr7 z<7YqnJMX#oiMRaWPu<$hF8g9&%^|JuHwG@I->2t zA5Wg(&m9L#YvlN(Ki?1jTx;>#;GN>n#b9ZT;LpY2uP5@y>dfkTxH{`yX~w``|GP&c z^7rHk{@ii!w?^>iV({lkjN9Oy;?Kq4Z;jy3#o(_e@+UvKS4L+)tw>meElckea=)JU z+%FGPP;qKHZC~)@6oS(yP15P{ank7uNvFp#>GYVS(_@lO9T|;I+CGj+r^h6nHX?Jr zcvwVV4xT>RvQ(>Op7HAc&$C+@K6H~sb6Cqr=Uj)Utpf@>FpYe#VH2(CRD zjSj9|46Yr)bx&kee&S2L{q~_R!R6Zr$$I!ypo-MTmj+?c9t(}D2$41llNO=UB3us8 zaGR}oHa6izL|sYN&)py5E_ z)JEadBAoU}-@5wZVzfn{w(la+R=)W2W#pwnXtT#c+bTkvjY6A6XtO9@j1TbuN!uzy zn~g$SkCci0Y6*MQ{N2&uC`;w+H10T{ekv>)Nv)*p@cru9ev&SO+RuSoEqewrCNI#26zX2K!6zON9NI#26zaD8N z{1QuG`Nbxez`dJ_)SLK8bEoaepj-mYv2qDqL%9T+s9XXK$|caCTmmf`4iwt#80Hda z8q6iIAwjTW4!%=0h{&`XQ}Wf~r9sHF$3o^RLZ*#ErbWoK2$=&UnX3qy zHVTVMZ()|TJ3Gi&!}Ru%DKd?`?7tvOa^?HbCgHBp(h24&V7lv!)haG;oi z!`m)b%J62^8kAYvkYv^<&W6l=2y$AtnB=-ZyDZ-!K6ft#$^0QnV=>r6V#wg zP=hi-EgB9KPWv+9ZFn<5O@o=B4e4yCFD@n(`+Hs0Zn>o-@;auAmj(2yOOQXj?^Svr%ZX2yGUjZGfb0 z6`{>Wp{+++_)^Nzy>ZIHQl^?`G3`4`ht$XZkvdNfA=4fUnX3qyHVT;*A=4sc4$yF* z@+`&-FEVX|$n241*0N&0NZPXEl-bH#Wvtx%NM^<`?~?Y(pp0JMQF28XtM}y7NKo`h69yn zo$WipHoR!F4Wg|_%E2Nfu8Wrm)!Zwr3*Mz(SWe?jZrRoWUEO`MKql=meA$tQb7!sMY5%Ok%to1;t_PcD4T<+5*)uO;<6Qj>UMfwE2G>BBQ$0F5M5vgXQ zVmL7m%h$O6KW~`MP^^2+9CDvr9sHF z$3o^RLZ*#ErbWoK2$=&k94IDK8-+}Zkl7>2EWY9%)FRVv|B1|g->Pz|LtT#g_|hO` z+G8Pe6(Q3`A=4scT7=928V(dPZ4@#sLS~P&I(F54F(&bgaC9ka_u}ow3(@^7_lp57 zFWk>!zI&y(IDV^o^!BXo%Z)zYk}qVrUu+h7dUfCT_tkyxy7B09&pz`xRrh`HXFvOa zTR-!$55McyPrU69e(L52KmVBz-Tbld`{{SR?D;pn^9v99>b`$+eRW^dkZntcY7kUc z#%E@g=rq^G@TpiMd|X@%-q#4$T?`+UH4>km!8?r~rVog=HPiE;4kJddt1>|bUU_0= zU;7>_chr>W83ysG-C4bk$eoM9-5SB2i^1I*VeDKC?$!wIe2m=nM2__I1nLLdc#d9| zJB@RUS*a?yuBxw{vdLYo5RN|dcaoh#YxrCv_;fM3ejfL2Oc+nsS}wWA zt&IsjeQWaB6M54%rSf^o>#D}PDo>%Ha*sOc2elgWK_NW9g6CfxKEGmI%@;LeJI|^k z!}BVUXBUI#HG*dsgUvOu(yun&qg6|6`{~Z;cyi(iZ%+3s|b;UD4Fb$F1W|YA9rF1^tGzE3i0J@RsYNv z!4KUiXGV7l{7`NS({CEecdovSOqQ?P88p+kO+ zxr*>8BxN=V zWvd8fHVS2{h|#i9C|gA+8$?N2k7Oo>_qY>7pcE`Z;t{_k1t)cU?Tdz*>-G&@1G-PZ zL`dKokUSe9b!#C0+DAy5ZE6T4Y!IRQ9ojqJ_5t1h{ov)ThL7KC&aB}h?fObGrCq7v zn<7qO_IX^QNITo$1d(<&inLor@~3ZTriY~6DkAL$QJliGV<{bCG>$tl1WLOyeo<3a z(yo6FqmDyX%LaN!3kmvZ4@F`Q7(C=hg=@- z87uv#?NfKR zp>q($8B7}uS&K0|?!*viDlMZPCqiW^omS_y_qCFPF;w^_dbiMJ$yj zD%Zm`luM~wX# z)`wB_+!o~x<0m|9I#POWPwr&be{Q|lVasntZ~)VhXpYPC^JtqsXnjQnvYhCm{# zD=wV&&$*YaT`gZb(uNS(Kh5GaB#|}>k*f%iHVToeh%vNLh+IX897IWEkK`*x{kCWHcI=&Rys>viW_ctj0vj~v}rN}juB283^TthjACMrd)p%iJO5ZRD?#TXuU zVhAL%jyfkh{jL&Hl3PxOlKwer8_I>S-5YcoVvRIWxkj#`Tq8|Xu90hqF&w;QZ85Nl za*Z_Ii0c9e4a!%H{Bb9SKvxHT`F7c4<2P?#>Cfi%d-4`Ty-h7twDXFICF$9_^Yb_rl`#o}G z>$+OL6xpu7Z&6c(EfbZt)=-KxQ7Li_VykL-G|Pf82>7kjN~qEZ-*j z{fwlf*6HcnM88|bhOo7cB+^D9aup%cMj>()F@`n@k*f%igD8pYk$lC-A9rF1B(hfa z@#Es8vaheomB>;=hRAg!kv0mEs|b-c3X!XbF|<*LTt$c+L`h_iWbS*KqsO7 zMxc}0J#sJlvz>uz@ORsXS|nvQ3T3MZWi|?Bs|aN_3T3M({(kh9`QFesC}m}<(!-b` zD5o_lw~5_B%WBwf1o|ys{moaeywb%2$}k>e@m}0-Rel*Z<$ZN)zPMBue-X9Q$JKmY z@!r?H;jcWHZ{hjAzIgAWHy-`kv(NmRiuXSHlb`wd8z1?#-+$XHe(*ED_xT&|`02O( z%FQo->??12#iw5U!!O+Ii}(K3f3w^8Q@6U`impAVrD3|buQG+UUEi&TPsNBw>Dczs z%!0m~Lvs7!-9gNaVa%QQ3bsFvZrj(lzR~e-4UZDrtRA$LeU7`h2ju3kwHrqydp3HP zWS<{1eHI-dA3V`q)Du~wpzl^GP-q*qirfjGudbVjX;*joyi>LcqhL?t89m(jq{M7Q z@abak=?Ff1qES=n+vq6x>?34NJ;SFX_;duHj^MK=*)#dVmu8+{z zsb{!z1b2?$&Jo=8Bzq=z{p)#r+<78*j>Kz2B6od^GLH9oSmgKAU%h=#TlGD7%Z;{w z-&b9|`s!8p{*K1+Rpq&Duldh3+v*e0w)11o?Mu@1ZnWxPz!5w+Cl|CQeSabXvOO8|ar?HRHvt5phV8s#K zJA!*haNiS+u*&2{$H#pip>b5taPJ829l^aLxbI2!Oz!*F^Z2;;MD88Iy(h}?o+!t0 zb65~4^2Qi}r)zok8>`PZmIvaN!ZVV&uRGMaeL;-1MJTlhrxqc#M>^Fl6?3xMgRK*2 zwFs{kA=V%MEOuNXrJLWrNbPL222bwA|3vKP)$}^)D@3gk^)$ zvPD>KNS2F`P+vrTG^;)P(!mtD|Gm9bs-;MSQlvpC(x4RCkOr#M(IK({4OHnwiZm!i z8k8apN|6So$cDE5A+mw3e<{);L>iPLEka~N5?MgW*Jz}ATrX;6wZC`B5SA`MEB4Q>5HWCL6OQlv$QG$=({gvf>@vX%~V zD^p)ODENYkvtM2Bb9y1u^@VQRKrHtaZ+ZizY7om7Vc8-qTZH8vX%tI!ohjai9 z!m>qJwg}4>VYx?J|75ubTmP^;K(cHRmIp|ddnA!XNEV3NEdvo51#H`SuliW3+7M|G zA}vCsMTqQ?2CCHDNn{VUP9V}EL|TMMix6oMB73y;Pa=D;^$(E)B#{;&a)2bVN4lW> zOE=2x6t!;fsklX;U5rpC3y?4iq{{-yvOubOuocz~{$85;zu)>KNftls z)Bfe{&{EU?eFt>Ugc(3-835gU(IU2jLKebVL1E66`vj}?f6QM7rrA5q? z4JpC;4FOYGJM~d-O6g!OZU&_?gHoA6sjMLlNvWelSp!=qQkg-yxEYkn3`%7NrLu;$ z{-Laat$(S^B9s}F$}B=zLt5NwEhncib~8Xk_7!jHV^qB5RUL~EX%QkVLS&CLP;o3i zGCkNjfk=xGX%QkVLZn5A?9tXgiR{7FKSU0YL|TN%0g}ibNn{Zcr!M8&;qpd+NbW27 z)&Ek}#?V`YWQ&k&5t4hPK`b?Q%GMrioj|fhNVW*c79rUpB=>0RpCtES>mQN_NRll= z@&HM4k0iMWiQnRjWLsv4$oTDgcPd(|+BCctA<`m5T7<|RX`td*d}Ml{fhwIqq(z9d z2$2>c(jr9mXzQOu_F(HDA_qt!Ekfh~No0>CvW!Li-d{w{$_z6x+od3NwN$lbVj7f* zX;3DnL7A8hX}n6!9f{e%)`?6^gEBD<%EUA%6Vsqf%!ao9k(do^{maC(h{QA~6VoCR zvmyB`LZT?K__Sq)nV4<)RUd0r8;NO9CZ<7|mcvPWD0 zB(euv{}4Gq5@``42S_4&q%W0kWw)eSq^8m8_n}j73PPDhD6jcUMNXjfi*#JpdkCc1W6}KVUfq=72cbrLzK<6|LeFcYhPM63O z`n>@q-vI0%%eR#oGJo1K!#sCs$I-{8Rs|{<`X^6sh_thaw6ln`>yh45sks{-DbHO@ z|46&OxvhVsokgUbMWkJiw*D#Ydaxadv>PDKT};>W+{Gf!T^iE4%bV_$d#UPnc#Kv% zO;v9SVy28+>~|M!3QA=Lr80w3Swk9|?h6 zA<`m5T7*c85ZR-xe-hb)trLhGAc?dHkpm==J(9>OgiCC;pfD%Vwp6N*rG72*#GuSb zgEAux%8YDC168W)m=+q?I*}P^P-dh-nUMx%MjDhE+0fQMGO~fKf0>aMk&y;vMp{Hh zHYAZnNYo{ka~Hc!AR_z1HubR-6e7|hL|TMMixAl(4OFSFlgJ)yoj{~Th_nci79r9i zMD}RwpG5Xx>mMQqNFpsl5e-MD{UT*W=W4)OAGKsQ8ufAW9<3Slp=J zCVZKpb{UGx0$FAl=Qi;|T-wPZ!f3esfZWv`W@TcnJB$G>ZQOhG-EEJ+ufDd-@cn}_ zL)9_7a`;axGkp5B&)=_QhEM%1xxQKc7ysTWg-$A8d&R@U@-<4;bW=S=n10VQ{Z)LO z{@Ob#u(*A7^|xDj%~OAsnj7kAvSx$3_OI%_`B$Yg{dP@M?i_B{q;5PH-Mn49`CN^E z?rOERqty@ahT4Wgt$u($w)z2mMr!@Q@jdA}FODs0^f%>>=DcsczoS`y^E`i3?y-yt z-ZK05_gLz0;%-W{Q}58@Z~8m*^fz(Wm;UqH{O3g?%KbO-`7EvWkG@uO^_~bW9ryP{ z2)q1RuZkZdQvm`MFu2Ot5Fp?Nr)`Yw za??UUMe~8lrkg*Dou@eG{^W#n{QJnFPcaAm3+Up&2@449rBl&>W^NqRYp3fe0 zt-aP7Yua4mea`oo0L#}S;P&Mdbuv!E*4<|Rsv*~#8YX&bKeMNhB#WMtFp-M2EtzB~38_S%b>;cODM z4Vdz5{`!f>e|-02yC1y#9}iAfKi8+LAG-XdD|i3+6_5V$D<1y=ThjC`U%vR{ufOx{ z7ax1Wzxlw$vtRoBSFZf|Z~VdipZex^K9E*4eduNXGK!+rY=t5t%vN^g$EI2z5#hTRR18lNwj|!6uqE`T`W7!dxYseXqHQ!x?;U|Z7C>{=%gc6}bkrFdt9qH7`ND2&M*_}m zUtd!0?X0gT$95>y?cXQ2L+8ExduY_{-<$1mhJ1Qe^Yxcq*lGBY#m&s&e9IC!%+gvs z;yp`{!#V~ztO#>h5$2;J%wa{&8^avd`#}!hJA$7}ulJutsc<=D^~>@2A>4kG(AF%X zFBwH)0);VwV)z9LX9Ar)D!j>kQQSR5I9+!sHgEXPnq0h$^ZmS*X&gk?{qf(BJlDx( z@6df1@i;0QPUf;W2eHbx8 zWrIvmfg(W#ic}LQ5>%kGM@53leKA4rA%fF(`I8sC^5sv+^eSY^%=NGfqRe>&nMD+2 zN<^6wQHw;BDbd-ZD6`lXWZqMRGoDw)=jIP_To$289D^;<5EW8SD^rqB>@Iq z0*I2}j1dr2?BkvRxTHe>lYvWzHkWETSM&BFdDAS|p-OiOwEHnZ>>! z^PVD{y1sDLpS1YuRe$K&A6JMux8DMUSVuAE&LYmaGKzDqKr!bEL=W~@#pg? zAoHFg$ZS&hsE;gTrp#jLQ2b7rN03=WL8e5MDG{|uM41wuJ&H1meL?0uMUZ)T#Z=1N zFCb}E#E?0UAhU>qOo=E{B5IL{G9@~D6lE6sg3No0Ak(GTwNpQ}%@5(>z~~R*#WcHq z2)fnOLT0XXSfXMEmQkE$CE_$IP)xG|QH$(GOtasQsF-HuzBtX^LxeM)Uu12%m&sC_ zX;_^|L$I1YY4qT)Cx_r>GjJw%u~edO&YsZX8a@nc*C_gZD7 zVxFBA0By4X*UkF}OHJWow*W9H0X#|oixQkM0``i1+%vEgGV{)qEM(@anO|s+%=D+n znPc+^=~qM{{Ul=gNyPM%i0LQM*`t_##lDb!_Y@)h+9UmZFh4)i4`cC2zt4*68yt>e z!kk5%OJx-2Qh{PF6^L5wOSd{mdk5&ZW5o12Vk?gPCb1 z538YaM49slGK(n4l!!7Vq85oLQ=+p+QD(6($h@ZrGF^&2|LzCITrvZhUOpSsY(HKt zD5lw2#A#MWaheqYGeM| zpGBNzWfZ4bfnu5!C?-sSVwx4`>`^hz%6)N~y@v=go0Ph+&~BMpNtV5YIqC@Qsdul@ z+C`AdZH&3oA z3qSh5`BU{*cB9HdPB>P-wwxuPDgrfaBZ6(wv^WZTAF6>jUoUI_sa|K5mm{k z0iwntia!`+=kW?s8O3pwh$5>s2M9 zL@<`ueZ;q;MU{xgWYhprW)a2nV%bAvri|h!N<=LZojq#MAZ6xmKxP}>!rOiB5|eG)Sj;UvTOpZ&~X{P+5sc znMD;HPG}1RW3!0HWYhprW)TIM5>aM>AX6f0k%(h2(P>eG1}QV|3o@f})MfsDEMRQ) ziI^OhYD1i!uhZ`^g3KbSL^LL&28c3?D9DtEG7AKm5>bmp9D9jQiyAaYnR#E3=~C?4 zvdbLRFUGm;`YzY1eo-QbvvFIUM75|AQJjn#AUZ9g;8Y^pSR*)l37%QSmL}M~)fGD$wf=r2QBx(eiGKyLx;@C@c zTGXIH%FO$M%vQh1b6Z)zNSS$gBuvDqez8C>Hj8LXMhy^U7EzEX5oHz#G9{uGi8%HW zofb7{kTUbWAk(E-U4YNF)AfrXdi{fXRlg_^MbDwIDWV1_M9V0O7AQnZM9~6;GJ(R2 zK&M3w8Vu2WUl8q5?8!S9E$bI4v#7np6kH(4oJBMyqXvjFizvvHh%yTVnG#WpL>zmG zPKz2eNSS$Gkm*IGcH-nR#Y>nm-KNq-tCd?FRu3a4sEjI6WTA)}ph!>|#RL^75>z54 zs6dfw0!2OvbXwG)!AQ`)FF0*gjZDz|(rm~~rHdB5l6e@xSP@ks8k122M43etWJ*Mt z1%gb8s6`@>x6 zf#NtQ5s!lcQH$&$$6lh-q6Q7daj^FgGMg_x?_Vs-ik}wyuWw1Eiv@zQSwxvKYJe!S zh=NRsD6>G2DG{|u#Icv?w5UOYl$rMhnJy)kHyc$o+BeP~OzPL#Qz>Ez@JN@8C;)`X zh!VhK5m5j*lo1#@31C8kGe(q6!CKz=Qq{=0R8}=Y6%P6Rw+N=$RaK)zNWUVgM9eW6 zH9$(lmxzfZ(b=O04Kn?5H;{fVC0=_Ys~RbDzy5kv)wsUx(GZLk zQ6-`=88twZSwummM4U?tgt=5A%9My>FVSgHg9a%x?+Y?pRU?1x%c@4o%xlFUGgDP# zfnaPF(U^=HAj&MFAX6gBED&T$L@g3=>?JxaYS18M=6&I3<0rXzve&~=TV4nT+72UV zE22t7H!^B~sI7>CHi@XMK+q-;1xUnEmguyoL4(wm_k|p6^^5%3DC-wvLR}wVRz->u zan_teG-ivAu6~60z6K~JR2jtyRiKzqCE|oC5Vgo2a_l8KEo#tUOsKtw$iZ(h2U9ts zHL)7X!wA}ns1nhQj2a+nE25xHB5ErTv`It(64}P{MhzOIw%iRrsbMh|&rsD8i1sj^ zm*_2(iuNqDKh1tS3KMNC{V#4u>7rfLvkmrstX`>z4OeW#oA*l>Q>EhnaIjMG*<7jk zTUYM>*()CXvsXO+%4?te@;m;;#hd=WFTDTSC*JbHZ{7a2fBS{^Up)P(Kl-_AAO6O_ z`s}Jw@xSUd5b=!ug>~_ETV3y>)ofhsP+7or5%I0K|IXFrE8PjsbQQ64#e%SuSH;+^ z;4%gmx>d{#`_WU8x^LBf><)F=THS++a0XYswi4K4KUO_a5N334tyeEv_34k`D0szd zQLp*G*LSFW!dKjC;O)0rza`tZ<0s~JJKU-5FsHXaP;AFA7wTO0kf3Fr1t591_+GrK(C^BTC z^>c&1T^IE+(bV7TVn0lf*7ao??(O4?z1dVAF$-98gdwO{R5Z)c)y+hL+@w=>bU z+o3OiE*2l+-`k#Ucl_U%`M;+Z6*5)xexlTlsj3K5RgrjuMgKItHKdS^K{_bHRMiBj zs!6pUrmBvqdr%Rks%nj?st8k65!<%+d3sSH%f8E^LJG7~&?RB;bJ*+GXiy?5l!y)s zguyKkMoJ=TED$^vsN5G#7Eyz4l!!8AgS06TbxK5^8Q~Yyd%58Jvsdg8`JkPM-u#j4 zwyL3>&j7BMH{|hi?bPen;s7d896<%5Q@I);&qoV{5tAs6qGDe-!6~EQv}kaE2Hhx8 z97{zH<6tUK98CqH)7*LdylO>-{CQO@ZwQ(DFBA6qHOiETG9{wS0$~&jgrSm%G7AKm z1uFN&1TCTl-6#=d$_6P@BFdDAGBbkAR#eDmb!AZ@XWRXye!PB-G9{u+i72x`7~le7 zs3fAy0zqbh%6(C05jE&Wi6~PxNSP8*rbLvP5oEd)d$iuC+41s*OwjekEU#aS1QjR} zRG>&uiI_AJ@#`;8B&bA8P>ITYMS{wxK{rYi2`U)Ej^MNv6|yoy78No< z&A{q(sMW7ws3f9Hi72x`7~le7s3fAy0u6dt?u#;u1_#|J5oO8-DN`cKl!!7jf=rhZ z6{51JFn*k@WqSP@Gc(_e$7!!4%9O~uU|I@<0WJ`RN+QZE5M&mp+!tjQQG;%jh%#k^ zlqnHqN<^6%L1rr|oY8`UyCECKyd^Wh)(Sq^(y0AgBa%mVZu%uBRKUjv=d%Gg36-8NYH-43R&p&YmuM= zMS=>HdKiaJ zf#T39P#iit!o`B8xV+la{I8$of4!Ig^$T2c&CAiKbpLhm4MC|yR4Ngb76`*!APks9 zR9c`x56gWqKZ^zj-6#>2$_A-aA}W=LN;5)!w(>zf$ScbSDKl5|W3u&1HBPn?QKm$c zSs)B>fiP4OQD%W4vq0s(D6@zfbfZL+DI26ri6~Pd%FGB4g_;yQLGt6~sCbY@ZPZp1 zq}Qp@s6;d>5selIqg)`2mqfO)Mlf1LmHVR6B5Kf$649t^kVYk0%p$7X7iAVvgKm_FGG&95 zDG_B#M41^urc1FKqb{>p5)ng{tJg5sw$=AAR1#69L~$;(mf0G_02fghDv2nwK!YBZ z`=ZRE!9h1lM47Td%9MyQC8ErXAhV4jXWQ@K!5*9;B%(Hn;(TZQh<*&P$Z(Z0u6dt?u*)r1_#|J5w*z%sZAnklZe_fLJqc) zLgt_>DU73Ie=-0vy?QNo5Tqd=4?KXM!$dlY20lEON_2O2h<|4Ke{G zVggFU1k4CBTTvl@ILe|z%Cvu1GGkRf8!CxxKNX@(8O0xt1;S8CM41JG%mP8?JQ{SP zM3gD_MVS&&rbLvP5oCrG&rDfVXc6|IbNw35bJ{A3JSAKwts}}TqHqHb|KgQKm$cnGt0A$QyE*#ZrlonOAdxOs{I=G%FEhO2o`8 z5C*tF7%GV35%c1b4}AX284>#dHNUVuoM5>ci^%*+B|6bpo*l87=3gv=~Zxi4mB5jE&Wi6~Px zNSP8*rbLvP5oETi&~^fJqse^o#_n}5f9>wU9R>mgi1wsjUn&vHD_J39)EnA!nzq3P zpw}DXFYFdelqg=i(^fjW`?e@RjEclmfcQ5K79jq7EoUH<&F{T}we$HSPvd`vk^a0Ps-^I#etzB%k?r|4!uD=I1qzxc`rMzn^Rqo_8SMPe%L;d{p48S9kc+c5jP+ zn(u7^wtFRfj5gmZ0rYF{@d#hO_73P*(d{_xJ;*PO1N19~agQ`#F$64MYK6Xk@tR-Y z|H?J*d;6`-?@0cwjCTCDSKPGAzr6t4xjeg<&*iZbd*KBBrkzz#aXza6^rMtb^Zxnr zQOb9Xn$&o9N|jyPY-WF96K$WT_C5a&M1Gj_bkRAJd@Cnbrp8#h>X5 z#QygOXTA65S?`}+x%*#U@#ufStoQV9KmWxmzxt8?`|US>ZE%EFnvR1Fve075B#x z)GA)Xl(pB^xwiMuj-$HnQ$_45(_RdwZM*>H7b0XSmIS}$9aI-j?q`! zFdix`#UdW`phr&Z9D(=OH}5&(T-#xn;|1?|4~Gf8=NQbq zY;C_D8{`S|@=*IuJkjRlCw92KwXE)%8%qF3%IbdNMIW^C?j|n6+1_c%6;sE zm%irV_7iRO_h;F>423Vdb$@_N+v4H0@FNW#uDo&kgy{uKbH1 zdt0s5+RuR!f<*i6wM?rEqC|VBUlCqiCFfmM3k66 z4}M?Ul^L|#7XHL3&egpKXB&bpiQ-_lexR~N;Vi6(;^A&CDh_xuibuSo5Uf}asVn#I zj35mwoaDV6gW|~7KM!)whxM3R>X<)!ICkon(RkjHR~A@M%%3ufXVe14)Fn{NpAzv$ zh(w$}k3vvppKIHRGlFG+n0j*T1}Nsw{(0ciWyHhJ`I;v7=GQbSF~1xkA#uN&>$-2PZ`De^C$#Y_K~-pI3rjFDCSSuGtQp^#r)Y3B)X@#FrBYGV{h%+ zGiFsA!_|?lAxM;n5+$Mzi8y`|4WcNqh=Rl;5nS2l+IHfMAPp-_6+J5vCFUqdY%+Kz zBkmDkr+$xs37Oxnn-E+|M3)j-PwFTfA!noLvWSAqBN3F@N2bdhb4HNH4#M(YjzM&p zqu{c6+TP1SlYTFU5-oa_kPY!PM<7ZpqIjAkqj;Jl5Y-nA;?P(iNIVk3m3=Ns%rR#K zX;>jK@8uX|f4>(6iS3>n@95-vZfMf$mMKxz630^x>z@`LMTtceIwBFrPa;Yz5F{1| z5|2c1Wu4!4;*1~-D@D-k8;DEzcIwl+G>a{853bjNR-Ql;HIU8Slcs+5RQB;qJa zG>D3`9MKJ4MlBkI<&0p=I=}7289^F5_{zIK22o{>LO%M4+mlV3d)Vl>)!5=dyj}06 z0bD@q1zJl41WEuA5@6gUC?g=Qh`^OK(JNkA>j0O~>~rkz`YfB5p}+}g(VGH5Q;vWn zA8spvc1fdZXC_=;bOqlGfA}DN8?L|Uj+k&o6y{Ee_+6KX30ENgHWb}p!WB_SxQsBu z*7s1wk3meh90iv?;+BQ(GCv7|Ceu2o_Rjd#)YuSDMFpb7A_@{E;`m8K zi3Nhh0>RXg2(IjNZ98#BkcJf!i=LH;5_1$JHW}L|cCa^pVkZuLeZENu677+mm0ypF zL!Us@QAFWXRH8vt9Qx$Gczinw!IgEM5_2cc2-4W`&?kFVqB!*RC`fEiR`JQB{A3mO z=1*2pVtx)fA!cH4azT-a0!2;<6iFb^AS!+o$|z>yQ3$T=BX2u#Mz9PJuhJC5%1jiU zj!f(b65A7S{85;nfWzMW2{=m3ht!1lqp&x*AWAHv_`^m<@kgORlvp&#AB8ds5{syP z1XuQvDKW>K5iA2liFq%_AWF;@epH$D6xowM2R?l z5>aA-AhAF&btHl-`&`>joDrm9MY*rA9>q}GlDdBa9Q-MM0A;> zmSt17PinYJnt5y?dLy#yDB}zmc5^?+_8bncI5e11yBDk{8we7?i zK^j(=DtcBTO3YD^*s36Sc9W|hu(z}ps!3T%YzPu1qC|D?a zA_@|ZL~v!FixP9p8380lX;9rWNX#8rq9`#VNc0BnaUGYKYmE@+wMLX^6-O(H4Z)N| zlqeB(NW}4zXb?q-MHD0+iQvjU*R~U91Zh}ds_0pXC^1JtqRX%YrAy2;Vc1(*A2lx{ zlMp0IM2QknheRAdi3U-WSVTeMkqEBrBX2u#Mv%r1riz}Gh!S%Y?jWYxWGnx$yG(Y4 zHLCRNx1is4+C@>GC8ck;b+;AeU3#&yy`}K(KZq*5C`Ep2Y+#k%{ixFWiN}9@_hY*s zynN_jmEJG(D!m`N{Nk0nZ+Yy|w>j- z@;AP4|Hoc@>a$mKY4UrIFVeG;uw|0LM zK1=X5*D=Qv6f(q5v)tC~b`%(}Oz*;rzV^%X;D_aSEEVcMnvWqcmtW`CV=6*(ItJ!6 zfjLEJP7#`$CGewTjwdh_6IX6)c00_mkdH+py^s%uLdO;D!Kp4ngP%DiSnm)Z9 zk0z+U*V~~}|2-a9Q0?IE{dPMW@TZsB4{s@K z3Da2jpo(FF>lh^K_ezi{Q5%@sn%$0X0>_{An)|D$;!HGE==CG#F#yRYBMJa*MFjFs zHiZNffEg`5xvbL$SI;G5cDi{fw{SPfb?tCdYnZE)q4B2fvJ$M zKyWpS;$XP`%Cvp)U?>p}h62UGP@p&%&LX%fKGy_-t)((BH#ZNU(TB*s4L*ww>-G9! z7m2zm<&y{+?FZH>hgVg80#RcT1&sxQMv16VB5IsRa8*Q2AlOQ^fqg9I0W|u++5=*) z@o>eSefE`?RLoZ(+LTcx3Y#*DHYK7>fx@OhVRIHynT(o1khW9@<`J9+u-SC*W=($o zA~J7Pv)6w$ABW(|MsX#7Rp}=XH5O6OSRiPW$Tn7p8fDZF!Br78fnY1u2lla;2hiB6 z_IUSuzG@FALAizuhM!_>5j4&sYAm84q(IOp5j9FgSMvz2il_+$TQ*SJ{>_8=y1o7P zUAMYD%IudKA+sUMoI@c~Mp33jlqpaQmOvqM7SWiDnm};2)ClImod?LtR=LNiHNSlp zLcC&(zZ&zxna3e$ETT$8jYSkR76=+8qDG0RaUQ`{5jBBe%Z7K`zj-jRjwxN{>P@(8 zm3>Ib0zud;qRS!*E(-*g5^<~~qRV*%VMWvgg0iKGFgG_3kdm$=ifIS6efIdr>bxbD z{S^orXAw0PQP5Z*Xq1Q=C8EZ81Xo4W1cI%ljxaYj51_H>;4jJiK5nGks=9Ce!}%~` zHp-|HMK;PPW}`&RMu8$51&VB(MNByvHGyC&RTB2*<^eRi4!f@4za)qCed`}htI|J- z;3~hJ1mBAmRU&FEqM)%r&?pf#N<@wG2(F5#2?SfImavcDJb=bFut!w*^*uqzs`yVL zxXSnbQdEhkv511k0zsoh)F=@(&Lg-gq9zb*rE0=Hg7W|xTa6!2SBEuzm#brAl$F_M zvU4DX;A$4du~9b2W1~boHVPESMuFnkIE&z__*@eRwo*M|@5DU7PsmSm_25=`h7-^I z7q1>h5LQH$h{B2}2rCeTNkm~1QP@0!l_F{a!HbRIwtw>gdFMK;1ksOzqM{G2E){*Y z(0eIR%Mic-RiwHcQA)CVU83Ncw4PZ}33;-U(5c{x;H~Xo^8hL5 zI_%e^r`)h=uOC!lcva9R5nS2gYfFx%s1i|Q5d|Ryf<}p`Q6g%bM{rd{O(57x6@q=3 z<^eRex;@^B8r1FOwF@95mGl(|u4WN67E#bxAZV0`8YQB}c?4HQ)C7X9rA9C}HxF>e zV(U!7N-HMA8O#0)n-3!hE22t7VMP>#6$rv4qA-alY#zZ%5jBC}#l~>kzj***ze{1M z9xsTpxx}Z*_cdHH)aRh=RrfL5M`uC=p%FBe*J}CJ=0$srt`PTTn-1 zmw$NLl2#G@J1_sywu&fAD{l9~yr|S`g?U!X;Z*h`L<`cs7d6rn#w z=uZ*)Q-qd|BeCf2e%&6tVgbH8^f&@@tknJ%wk2D#fmVC6=4=D!Yo#VS*!@N_&5U5 zu|POZ#l2)K4x;^<%7Wr3Do`9o1&ZUSKye@yD2}88QCTh}Pd&uhm_%_f6%EGGRG>JV z3KYjvf#QHFP#jSOibLvA2&eJx%WnVqX?!m61AG=su|H^w5o5LWXJaY$XIZl1-<61_C8BAGXj&qgmWZY$Vs=VI(-P6NL^LfCO-n@664A6oG<_t3%qE2o zILPup%FGr2km>biOk0U4QzFWgh%zOjOo=E{BFdDAG9{u+i6~Pd%9MyQC8A7;DDy}J znJ&f7vph3p5g=vCvNy_sKiRe@!I+ch{k3?|lQtW1kJC!AZk)Zve5M+8yS|q4I zk)Q%af(jG~Do`Y-K#`yVMS=UgfkLT3p;VwyDo`jDD3l(BFs-)oKwbvQ z<$<_s?B#)wnO9<208~lkS77uNdR#YK$`@RCIOU50AWV~ zC{j(JNHu{X)dY%E6DU$m zphz`=BGm+nR1+vt?I?s)Yh`~tgOO!_l$l>gjx!jqGowt2C{rTJl!!7VqD+Y>QzFWg zh%zOjOo=E{BFdDAG9{u+i74|(1evY)k3TVF@gHU87uGci^ zlqnHqN<^6wQKm$cDG_B#M41v%rbLu^B!Wzr67K|(C4iBc>)R_{a~7E?P-Lb+k(mNT zW(pLUDNtmlK#`dOMP>>VnJG|Yra+OI0!3yD6qzYdWad!_GFu@ar&+nwA7$F}ddt%T z$V{(1qfCh?QzFWgh%zOjOo=E{BFdDAG9{u+i6~Pd%9MyQC8A7;DDy}JnJ&d16!KsF zvK%lnvsbJ^rk4XoW(pLUDNtmlK#`dOMP>>VnJG|Yra+OI0!3yD6qzYdWTrronF2*- z3KW@n6vBCHE()Z!yyhLWwJJ2;KVE0NBWjaT)Fu(NNknZDQJX~6CK0tsL~RmLn?%$m z5w%G~Z4y!2kq9~1r0`T{R2m4Uxu6)o6uo8;i6{YlN&uS@z@-E*DFHl60E-g9p#(4} z0sKh-dlJB%1TZH7yd4GL%!fT*;4Di7BLUYxUs_EX6HuT?K!G9w1&Ra|C=yVhNI-!i z0R@T#6etoQzFVd5cB()Fu(N9f^>Gt&)>DC>QvPqvQG~Vyj@| z(NUl{Itmm=M}gw#C{P?71&X7iKyh>wD2|Q-#nDlqI64XxM@NC;=qOMe9R-S`<537Q zn-m@$WmzF*?!U3fYX_N`5>ci^lqnHqN<^6wQKm$cDG_B#M41v%rbLt}5oJn5nG#Xv zk;qSVP;g~0N8zcCEG|Uppgm*PmjT2ALss(Is|}<4uvHscS)u)fE$0x$h1c3bftPpR zd1LpJkH2pBT3hw+We1B3Kah(Hzut=r|I+^RVEIp8_V3fTWN{%LrN8jCl31vAT`n#c zg;2Mh?k`}5HZFHJ{`R%YTUMHQ`;AvFFHv;)k~^2Xm+l_kz2x#;n6>QxwQF9F{4dxa zB5v)_s;~6qt?pV`V_&)Hp;qv?UAgI{#mY@6?|GH&8ijT!m3&oeldCzs>&BhGz*ylw zS9TBl-TQVZ;fk)J|F3R;|7!1Hlo;U4(bfmrKC+HOu|1rQzww8i?b5G*f%3!7faMAr zfWAzI9Yj3xxp?NiKcb19{8R3vJuJqKGJn_zNHK3w{+vts28r{T^W#E zsuSZ~T`I!9#(NPlYNrsp?z7igT?7qs-Ml?)iG zY^uM9OSK&))pj^h+wH}za{KJx!=G+{-G8il$R$YCU)wg>%ItPH%k9GpMjzMk;AQ`K zkM9Sk4ix?8m)UlLJWz!BrU>&*5$2mB%pFB+ThW74Vwi7wKghRC@Li0>NAva=8`xP# zd@qFgmiGgDv~$1PTQ6E_=Rn6VTH1dH;Y0D%7KyRl&cxepXX0(QGx4_DnMm91_5tO7 zXaAmwr`kv2_3e4N(Y`fCfeH4#Z)f85cH)GXY1dDPahSX9r=;8R=9!aPe9^cNCKI<` z1@452yCsphTM~)8C6TyW5=povFpc!sl2c+Laks7UK$-UgyM4LQZA2vQc}OH~Zw-k{ zJAB{7{xYSw65D=;P~Z9{Wkd8QPzV$#3@myWo`%9^ZxH9|O$mRm~rO2iK>5mib=DH2hoM5jejrEHKYGlI&r4C%VEv0c2vS#6Gi%I^VDWo{5ETZ2@Y zFO&g(25*jdqzr_x#QY4~EiB5~6O4%S)W(1Ws8iP+oUpWRUeFRTKRGAxu%BEy{!5Vw>3)V6F_OC!f zoP7m~*;gQb=v>;88st1Bq9~<^!jBS(PK%1!S9Bw0-;SWt25RuB=u7tRG!(OM?*vq~ z+Y7v;J*sVvQ=|UV76M|WO`l8!ic=$j_}TM&U{ixIDiPgC6!Wau*J)8P&x!_Pp6v)m zZ8Qd-irQq?PeU=!_D;a4D~b2w=9@tnUcVU>$=Tx)VsZ);$th5LjRlHRBY`5#1d3B5 zfliBx)19k`{6efllc5ZdSCgph^%PXT&V z2o+KB(FF>j0)-lZLa0EeMTJn&U5EE*<0nS5Yz5$NC z`3-QYEEf5MN{OgaB7SIzs8S+Ik%%fKIxUJSWrI|i5meeh4L%ip<(Oq~PeW9h8-z-? zW#?4>8$>*=fSvl|3Xz=s;$K2cPJtph1&Xh+K#`mRMVbi|$tlojQIVXY!AQ=IVAKX` z@TnpLd(1L|r=dvB-XM&&hf8QQKU{*nwW?ZNTHk*WS3^)K5midW4=oW@N<=9VQKdwu zMNy?}kSa5RN*j&Ar=qVMvkdNOh$?e~P}v>@;;+j5C=mAMj{-3(^J}3KqRM`ma6zF` zp!m=Ng-U@!ia?=Kpwps4rD!lzb_A6+P=ikuzIx0uOsAnx*&Bq)W^4Q47WUREYMGV& zgf}6o6ev^*6dzikP$^JI5hzp&bXruX6b**Tj-b*8YVfJTSC3gn@H7-EdxKEvw(Li% zA2Rb}cG#QO)5h6Pk4p$DZMc@lw-!W|67fSzM3oZR#%fcf@r$tevY>+B5f=YWTc<`yHGRHurKS_KVqRQMLRC9m!ZP%;h zYzPS>5sga3&n^*-N<=pj(WpeHMbW5ikVZ3tQ5%iHr?S7UhUqj!qq!4E!$09##a?Ag zX~o*3kR}nONyLvQ5v57Q;g^WgBswjM(qw~_mJy`cKn*?>&Eyzlgb(g%h|+R{NJDS% zh{aQ__-B=WUJ(#8sz5QL3d9dxG>C7iM3f>?%&1~tr$xn#DjJL#wIiss(HMLx`jTBg z4aJPwJAwRr9~W{=>!Id_Xz=Z6h?8c2SZ_fwX$r)TS2W1!MMQDI$&MHD7jiB5~6O4%S)W(1YXVyPJ{BS>Fo zL{ym@#8L6dUPfpS$a@{u4)w|?610j!+hCVfdtD*^!c;`npxrX@4fjXY;H4Kq+d8Xv z|KWaJp_LK7?(x^|{#Pr4e&Apk;XmqSg#Xj!pGFzsW7nQR8R6%wjPUYfU;p-(uRi;& z=YRCcpZ>&^|MMH)`SPlW@OOS{Py&q#h+IT?ZTXURg74^kcV-B`p*jX<0E(Ov!*8c* zttR|#s+cW7ZAAQD$`P~M`&hC}aXXg$isyZxE@D)ijy9uM(2Leq=15@9zUvc&7IX~E zDRN2-{ixPzLO&{IOJIm4gzG-fZttTEb86o-mq{-qyzi(&!hX7jy|IW5=4`55nHv*Y z&@nKl$SE=Oqgty8{iv8NfuUYV*hgS?dml@fQ~Rd5OrnU;4!+-O5n)d{bi5Z49={!O ztc%N6ptrNGTaE3re-B-%{c1Z@slVUfo{I>3?`O8gCo(m1j}Vu85(R`zxBUV_NWEi+ z=0}RRXQ0eby(t50roQXgHB))MZdW-LUHJaBx;Fp*&5+1T6~kQKl2c+LW4EoD+GDM$U}`h(F*XN@OS*LY7P5S zPBrv=3?Fo9@OHaLi)G8r{MR3@Xg2-0Mo;XFv=0GqM{w3!iX z`oP-HEWf;!Un7JjTX`XG*!AZq5`qvbc3Y<3V-STDQAkROPK%-|+28=tm5kaV*jg$N zbId$QqX&(-u?10MM)(o^ah8gD#ai5tTU%)e!sNo8%sYuV^b#?HBswjMR%C+%L@P3C zi{QmZW81%Z04r@^Axw6F!ZL!e{<W9t>7%;2QH#`5+n9? zS`=N$eGL#@$*3)Yt)*5l$IJt0Yy%68xv>RNV@A*zI{dJgUu=iCRh{7ajG`gVqH~Bs za*c7?7c@$AS`=N$1_y|)WYiYHR;m;1V=)iXIB*v2jV&l<(T*^SevQu;eUnGOR`SQw zmHv|iZKP^ID?nRi<{?A_MHFXI(co!Olq2^wK$Ih+wg`IcBX9dR57NML7M0_$KSc2M zT?+diYX0JMWxdOIqoik(KsF6jC=zWhDzZsL4Nzp0h}t4}ku`tw0K(dsGtoo`BGEd+ zeCbj4iG04yuj)jTUZ@tgPSxsvn}@7iUVnE4#mfoQIsjrX;EQJG&n$EOhj!FoTUoD9y1Tp zz*1&!Y(XKjBgp(^u8!%IWAU4>pR6LdA)c+wArAe1k$-EDlc;F$v?yAU`x+oxkx^R& zFV^{O|K>p&SWcpH9QKC@lV}^g<7@oZEw}PNrr#WjM3PZVKY>n*iX0OS4p8Knh}t4Z zOErEyW*)$18(5}aZ)`!4ejUN4N7;+y{g61U?_2*mTh;f?AqvUwJV75@gU~3^X;E|~ z8yq0Il2Ka(TdBUUkHtJl;{c7a1JsxiG{)$;#zAeLJ$SYH+0Y8fkd3p5LW(G8l<2f5 zx{?hJ5M9ZrErPA3wlBxb188gm3yrz41yN&0(CAV2ppa|KFJwnts_WZ-$w@=dIEyHx zh=N9mPK%-|+28=tm5kaV*jnoPa?CtPV-AhEu?10MM$p)5`uMbMuIWQ3mYO~r!zC9J zSidd!Fo24pDctE6`6P zhy5W!t~DK(K4lJS^{fVL$u{iG3&VK*Wft)R7g6Y=M5jg3m27Z;=t@Ry5o|5BdO2nu zq=ALT+}MIRWo878txk{Uq35sDiwkY*??79V1W9SVT75lQ;U8>DM4J-DkP z8>B{oVwePCHp+d4D-kt7;Yvhp5p2n-y?KxZmKsF|LSskJ=sN6?8P_P+Fr`Mhk|tfv zA_^&@pi!dJqUcICI6!nIqqYdPmO8#1GY_D#6-Yv3Zfrr+m=R7_y#q&7@bwEkXmC}* zH;1SrzeXrE2#FG%7DZFC!2zNv8MQ@ll`8o9Sj>Yo4v;82K#3V47hClne{~G2_xcYv z>}^%OH-{+XX8I+V8iYoPPK%-|+28=tm5kaV*h-{K1A|B%;IxUK>WP<}lS2AjgU`tl+&4Xo#_~TJ_fEqLM6C9K}+0`4K z;H33SV@W{sVSi!c8?}6Pan3iST0Z=RH6OKnRsFKm6?Pe&m7Q{Ij?H^NUZv_QAc*`&MnIiwG`eh zzoelF<`zxvD+%&Y#a#LtN19ZvSrKK?n*I7LN!6OYF7~W3b>EIBFvmK-YhI^-R~4Zo z<(mI{-TmP!PJj7roiIy+^KDHqMw(zGG(q1Lp+8Nk*62^g(4Qt%YxH-%nCg>S{ug7g zCGZCeRslGy_M^Z2CII+DO&{}56Zq2vW26cCt_b~UQnf~ZDu(_vsam7I^Tkx3l$P{< z_G91=7VIouVN42yE=fdV1u7e)GZ{r`1u7e)wzEc+ADQZkeeFl#Y~Fp@#O-JExuy^B zS*&#Rpq(Gx{E_SS|JeV)(`R`_L7KKIvL*Ro+DprLlr0cU7YO56AbbFcXu3dUgETFp zXu3dUgEW2CsPZGzbg{4fC}_)T3Q}7xC4)9E`lGf2L0f?^Cnx&33|q;@*_ur%6;ue!Kq8Ji)4O~ly!lWX_v)T2T3nPqs#(9W`QtN z1wtPsqRax74N|6zqRax74N~S=qsos=nZ>^Lqad?Y2l9@QtPYIXw%;zqZ0ps5G22SS z*;XP9m8`wH<(EYyirH2)Xip|r4aRIMqGGm{sBAE1+cQQ%i~Ptj+sb|IM?q#Q+2o;f zw2olRw(A7-^1zsFCE{!=5k^eb<9mN#XO}2uThU-lsox(o=-~u{QZ2TI_2- z3Nl@a-EDK3vbK;ib1@rc+o!HBS8)pjnFYd76$pKlh%yUQHb|K=iZTmSHb|LgjVeDf zWfuF|kHYEeu@#1)bUlrGg(36P_Pk2nhNx5`4wyvjsX(DrqO!qIDxyNEL}i1a^o&vE zM-HWOU;9x|n(GXy&F)FB+<0LdwG{~33WPx^5PBmKwH2srklJJvwH2srklM}~Reoe@ zEB3V?1#K@}_3LzBWtFH`tI?jFMXc;Kge$TIg1rJ^Yzl-qDjTG| zvqqI4nf8i(?MK1hM=$)fcwU_rNw@wn+7K_KOT?s;h+k=m_+1hxl1`$s!ALq06-g&i z*fyxG{O-4~$ zfyxG{?W|GdN2a!7U;9zWL6>3=&jDWlX6iq?g*&@nb9SN37LRP!TO2RnyiIxB2gi|6O`y|4*+8*0q>?4uA z7LFHXB#L97h_Zuv)nFX^L{uF6Bq|$>W8WF0kU{bz$FWcDYd;FpWnSfv+N`^)QT9?b zYAX=56$pb;AoNBeYAaCLAhpRTYAaCLp#6Q-!&6cDk*Tfd;eHgROP6AAo%HE)R6j_k z`GF&3ju))aX@TIhKp2(+p{o+nX@SZH=~PD1X@SZHDekOM;%W}b{<<`t-H zkePSZsPZE-^NM}#M?q#Q+#FO2#?i0;JcCRxO^c(SL_GRQgrTyrT%GT>2GK`};^-%$ z_^YCbDjTFs85M^(v9Ei820ffWklE@5IgQGC!8j(aZ}ocRA7$p348e0lJSNI0$Sk7J zeTm|jC>o^9BC2eVGG!EveZSD4hZ6`gedO&@j!&~s%Bn#W3EH!CvEZK-5ZVU2?Ga^! zRvu^@?9s_yB#6JTljA5Oymp66c3s|mPgD&?8DXj#yzgMu;D>Y7;2W;q{U?t-`cF_b z_?Pyd2g`qQ)!=`w_CHfJqBw$ zJ=|jMG!n%ghN^oMNi49{VH$QIBa+O#jG!{_2D_`Zw-{-#_U0MH*Qvy&cg zAU{+F*d9`1>*66L{Ea{KVo&&Z1NosB!14vn`0xjA$|K%yuUl?;L?O^_{Q|ZZ zmlD1Zu-{)9~yYmHE+PL!=jQ14dhFLXrR5a zies2xS%n6^c+LC#6}HoU0<$CeCotM+uV*35uV(>lC+<9b$S3ZDS4kkwPdkvC=y`rY z0bo0f@uTO%7HQ^8ZC%x$eEfC0 z*V+=Ow+~K$AITHoTd&@I?y*Oo!vy%uZ$JOFtDpS!cfR%JZ@&5MU%7bpOK*Pnl_!7m z3va*uu@}Gl*Om-DG0_JXr%Vp&(l;77CCkKHFK z366R?rXqZqfQqS!z2dUij~xz6QpMNAzp!ripgoO#Qu8Hcf4Z6V*<&9j4UZ!;{{w{~nHx`FpJD$YoaZU&qM-kKZ`C z!m;s&AEj?vPQAnqUN{QhY~-dFxnu5Nw&h<~$hJ1k*tcemFxzwtzJrP|+cZH=%@O95 zim3^+O~o+VG(om0!faDyyB~aeW9ii#fo!9L6HezK5H-0}n=c&);Y2z|s8+{7ts+#b z3DnLJ3RE#Q;gG8ss?`K)6`@*1w)=tFop|d!)biygT=BM7OoQ-bo4)}1$u^QXPOS02 z)k!oKPkr~Xi>LCdk$*(utAVp@el_L@zZyCQUkye0)zAcAjXAqaEap(iaA;~$VpnDn58A+G(CqX zMs|LHIG8dj=4#Q6K~&7vM@8XcybbTRe>lzealhVvy@=nSa1%>znzsE4P)E$ZSwy2n z6pTtlqXmM|S;S$HQ3FIbGKxkEghZ5xMkU(r3r20^xBY|Bu;st%@)ZN9Y*!38AFOY@ zIf{vN4#iX|qd1oe6qBh$oK5Et$7BDFmw&DS;_%9-m{LVI22nAo9u)sJ=E%3RE-vYt22nM4$&6;Y5T5v3If(q<7qxr`bh4!?||v;y%QNsI=i9T^2_ zHuBs4L0Z4UhJX5}oR z$|4FXC8Ej#LFFvsc*v*$vcIqPMU}F_K@?RU83mOlW!pcfbXzt|o|WQW1GM?Q1{#$$ z#WByWZ+3dr0L45jqd3nB6!WY^oM-0{hedv_0iqij74xj<#vm%@*`uOh)P{H4KNxLy zhB(jWJ3~Q|11P_uB1q{HmNqR9Qqp zr9@O&AgG*091j^aK$Ie*sIovjb6Q*WF{H{PqoC5Fw*7<3ri4!!h$k9g)a#;YR2J?s zIcE`#7Ev%N5selIMrRR+MMez}jmjt*Ef9=KM57XI_XVRSW!pa(bzAmaoF}JTj+H9& zD`H`}RqXO2`ugE2F5gKu{?WRZ6to7gXBtZuqy&xXHv>whPsw|?Q zQX;A>5LC`0j)#mIAP%pLqRIk6r9@OI(RN=@X(PYwA5=CagGF4a(h7W6D!uC0I$%AV zMKQt3206hB6cemOoM7h=$3u30fH=G|DkfObjX_jQut!BfrH%Zye^A*9v3aZ<6=Kt< zHL^154?NT8ETYjO3PvTO(E`EfEV93^q6Ua=% z)|}6?`5AP~v;GV^RpvF)7DSb^h$@RH^id+JED%)AB94cQ8Xyj@jH1c{L8U}gDbaRc zP`NA|n`5BTZAI}#t{8x?RxyBX(M3u9MDvKznl!z(|1eLRhzGTz@ad>4ERTiim zL#jM73Mx&?wtrCRw(LP$&&piifhaEoMpoMBFVpJ+AaV{sVi8f)ctfC!;8X0PS0%vb zmra!b+GGTz6`+WKu%jYg`kIHiLhJhPmueeMpRy=Bj)dz+6wxW4A#D2{}4h$^jkd`aVFU-l|^vn){@2}Kmrtc)rV$3yOGfG9;q#gS0-a1a$o z!lR;~(nfyUKit;(7GFowN_jb7_elw3>?n?TbBNORU-s4-5w>@RE}qpaHec_qLu$_(u# zVJr=}+sX{>r4qKmzN2^F8+C_KXWi-!?Gl6ig}uff#oJMLxI0*P_>Xhl;k&Nh{rqE( zejas)AOHOG@4xumr(S%|?T^0kpFOwx&>uYi|6ck0^Dn;Z_Q&7&!gCk7?(k#dy2A&` zxd$?7GUvJfI*LP7B2&cJRC$`%cT5g9m_4n0w7*l^=ZHF!O_tkcoQh#4< zhb8s*^>$Q8_UjihK0K}5ZigN9_xszU9#OxnASw)*arT;rxMVMDAW=oJ)nDFiF&#OlJ%Op^tbjxc;QNl)ld8w9O zUT2e7Jp(Pbl6IxO+BLYi0rXsYYVzva9c3uIT-_yZz!~OSku$&P8 zzS_N_29REJWm>NegjYrAb&k-Uim3_hshFD3o{Fgn?Wvd%fju2l5!zERTY|iVAG;DF z--Ln_aIZoOfqepB5NB2^Q(NZT1%<*n6sknj0EH|OH9(<4H3o3s1k)Z z5w%75nVc6|4Q=Zm->pKB+5`%1b12-1r~wK$B5Hubjffhca3i9Ih;C$5iNcMD+9IT) zOR+OwPsRKWD9n&v9!Qx*X&cXY)?X5jqD&bzKr|+!28hOF)Bw?#j2a*slTkwiV?|Vn zXiP?J5oES<&h0z906&9etzX)n=KV~(&?_&$O4IvqQ*3pHFf9>v&LZlRQ3FJsGHQUR zQ$`ICb;_tAg3cnUMARvxwg@s?r6I2`%SuDavIZvxJgiMJja~9E85sVd4C89AIwMDq=<3)(u_vB#)_S!Y7w0^EsI^vnFL{vG8=u1Wo5Piw0 z0irJ%H9+?FCPhXK5quRX=Bn?+P4 zqXvkIWYhprk&GH3Dw0t{1Vu$uiKs|MZ4qYG=8L~eWL;s*s{I-rvb5C|a#j^6X4N@F zV^`Akz_c&iwv~v+W)Y3ar~#re88t*ORz#JE#$?nML1wEgq)b^^80S0ddG#qQv&wl@ zR>e`~ETT*qH9(XpqXvk^WYhrBn2Z`C7%QSmL}M~)i)LyIsnq^m9V%OGAtzgbVzQk> zF{6s80g4$_L=6zV6`u?Di6x@9c?54oREg+KMr{$4=4Dnb%ywxvjaS-Q(HgW#L~XN( zZe-K|(T$86AZn9Q14L~yYKY*bh$<1?$fzyCwA!TbX|hozA)Q)<+RCYyt;OsrP|U7# zh~kQU;ZP|N#m%CaLPZY;D5g*mHAE06dl*mWhz2Q6Mr{$Cx)du)^ou~UmM{`@{Rnm| zTw{U?6bU+qA`3;-07Vvxr~!&B6j1{dStz20h*>D3N)%ZrqP7S!U5b?@y3G7Wa-2PS zeOky|UnFd$Ym_Na$ecrAOhgS(7!y$g6vjl<0EICTHAFNfqe>LUMAQ~RW|P9q%x{o~ zOs`OjX?A^`rxguCrj@X*W?)A#&CVejlMN0KjmfA1qA?jYKr|+!h6u)rs1nhbjM^fc z@z@*xq0Ot&s4YKMvLGHEC8D-jL^m>Ofapd>4G`VPr~#rI88t+3Q$&@BZe-LJLEA4< zn^&I2)F~DTr8fI_m2DmsQ|BC_8`t#SwwL%YJezCMhy_f$*2LMI2koW5LZN%h~i|_ z7Qtz&7G#3TYC+1}e+8YFrrAW`-@CQvRAVTkB6mepWZ@i&EEG`#6j>;u1}L&nL=6$M zP)3y~vQR{A5oES{L0%S+^@5a{7kt2!>cwf4DG`m$BFdCe14LsoYJg}=Mhy^+$*3WM zu_CHOG$y0A2xmO+yS`gW0HMwPYMD+K0BI6H*&Kiq8BqfGkP#(-3mH)Yc#shTfG5kv zSn<(v0qn1J#6CcdwMAP8^?}T>+y&%VD+0uXDG_sQ7BP`z)BrJ&WYhpLkz~{WF_C1{ z5FwF@s1n)VS5aF8nN7-|LXa|xnmEXmh%#ppjmfA1qA?jYKr|+!28hOF)DXc~5mh1@ zlTlj)nQgT@UZ%;b-68$FnvXK~-%HdA{dg*~z82$%GG){N(U^=HAR3cV14LsoYJg}= zMhy{+6;UOkF&VW*ek#M-L1t#I9sD~l|IyupJAaDAZxN^3K~#oVok_19jAFu8J80h} z`wRQQv#lLWrGk$fEEW7{E*1RH)w^GK?9nfvRPb}3dioz+|C4|C)?d2u%+LJEuU`G< z|McniT>pQ*@t;2Mz`Ni4kuTorrGk&V>_7ZeTcZ`_4_EzvUw9D&7P{NrxoIPO-<^x) zaE~GgkXv_d6y);G^@8mD99_3o?Pq5PcHM)U<$f;9zU_Qg);&PdDYCQG-8H!^N9RV_ zx0_|(u9q>_Hl&vWUiBh~UJeM0z4Q-Zl5n~u;dD#F>6V1kEeWSv5>B@yoNftc zOjV{APPZ}PbW6hNmW0zS38$UF91qI-D-I$jZ~Jj?y}BT__i{jb&9C2qR}`Yrt0MHO z2)!youZqyCBJ`>Vy(&VliqNYf^r{HGDnhS{(5oW!stCPi0-C{U!LK#__aL0eu%klO4O zKFbe8X!E)=YLkfCB%(Hns7)ejlZe_RqBe=BO(JTOh}tBgHi@WBB5IR}+9aa3jBpvl zrC9NxPo2-j)G3x1q|^KYNI3NhGdh)sP9>sKiRe@!I+ch{C8AS_=u{#)m55FyqEm_J zR3bW+h)yM<(~RJ>TwoA)WxTczGVOBRlAa5IN(mrR0%()~5+#5_2_R4c=#v2QB!D^z zAWi~klK|2rfHDanOakc20Q2VW^DM>d^x|;0o;MxE;ZC49+zAwiJAvYGCr}*j1d79* zKykPeC=Pc5#oj*j4>M9Q}7f7f3uh#Y&GCGxr zP9>sKiRe@!I+ch{C8AS_=u{#)m55FyqEm_JR3bW+h)yM<(~RJ>754Eh2ePn_GWQo7 z@ES78l!!7VqD+Y>QzFWgh%zOjOo=E{BFdDAG9{u+i6~Pd%9MyQC8ErXP%dp!?CDKE zt&wGYR4I$gs8S-Tl!z)NqDqOVQX;C9h$#DkY*yiKtQ{s+5Q-Gr}SA z*d+sFs_ip1PEozAFQ!_7VyYD=rdokwsud`vT7hD!6)2`!fnur^D5hG0VyYD=rdokw zsud`vT7hD!6)2|Kj-WJ`^-)`X{~ok?1sSzTL~RmLn?%$m5w%G~Z4yzNMARk`wMj&6 z5>cB()Fu(NNknZDQCmhhLb?>YDdN-WsH~4p@N<^m_Ar(J*;g?ChAdK4NYIW2m5w%G~Z4yzNMARk`wMj&6 z5>cB()Fu(NNknZDQJX~6CK0tsL~R)%72TJ;9MMxz7VyQqx~~27TCtc{1&Vo9pqN($ zig{I_m{$dgc~zj8R|SfBRiKzx1&Vo9pqN($ig{I_m{$dgc~zj8S382tR=~%3RTl73 zrYs|)Oo=E{BFdDAG9{u+i6~Pd%9MyQC8A7;C{rTJl!!7VqD+Y>QzFXD2r^s2B`X%> zl7N&ci_0ieBFdDAG9{u+i6~Pd%9MyQC8A7;C{rTJl!!7VqD+Y>QzFWgh%z&R%(f^X zuL;OS0b`o&S5tBN>UCu?%?cFLtUxi%3KY|ci^lqnHqN<^6wQD#Qa_Qh*%?<=(DWn#3q|4uze zv?memNkn@R(Vj%KClT#QM0*m^ocB()Fu(NNknZDQJX~6CK0tsL~RmLn?%$m5w&H68MKv74hr}v zQci^lqnHqN<^6wQKm$cDG_C61etBgKmLTw zOa9@Q=VfFuq4t`z1;vCaP)w)-#e^zQOsE3Igep)>r~<`=Do{+Q0>y+XP)w)-#e^zQ zOsE3Igep)>r~<`=+7V>7vOi{~To#Zrt=?vJZsQeZlqnHqN<^6wQKm$cDG_B#M41v% zrbLt}5oJn5nG#W^M3gBJWlBVu89`<%`{RkKTo{lt_ut;-6=swv5oJn5nG#W^M3gBJ zWlBVu5>ci^lqnHqN<^6wQKm$cDG_B#M41^urc1GV&Hk%HmiQzFWgh%zOjOo=E{BFdDAG9{u+i6~Pd%FGBdTbX7%0YVKA&Ssyyv5T@l zd%(`L*%Nhr$-vvT!QP!1Wq(%RXB({4GphV7`~5F&#~Omy+M{lXa(Q{38t za{&0T{?7ITvR$W%e1DxLlvTazp{ti%1MgKW`c>Y>tNq{C`@c`@Ah4|}LxEyd8T^eu z7j7k{p0OW{ha&C4WWcr-2GcpOg@G?Ze{d3iTE2gOd7nGay?#G-VGn~Lu034Di%wq) zXm1S%{3afDzqoVFJNM0i_AYTie}u)#NFC-!SOCj+9i!(5Zn7iZ@1)ze*Bk%ktL?jQ zw{ij7tw9cpxHX8s@i$1?^ym%bH%J1u8yY<3CDxUDPeyFh|jg^Q0|t~P$%%U|o&#x~)23Z`i%7ACbh1og-^;dmR%v~$&G z8QbyQpW|QH9A$rDS3qN?vfID50aKS1DF4LcKfe31-49+Ko`-&^&qF_CUz)pLdhF3J zJ@)tyTzlsC-u(2H&;9xb-j1b7|I_Z-fBXE8KKUCT`03l9dec*1zM7|@H;k%}t;1e< zdu2I|Ma>l^sG8UeTf~eAD}1;6v0*7%W2v}Jl%j7{4{X{jTC+pRG#Rya-(G8|`^C3< zfB)6?80QOL@;QIXI7HlE93l{jZ!itzw?7>wG^Aoi1cr1>MK}&BrY82+<$mB!wMMIo zj9P=c9K)LJZO^LhG0t?CTk41JTk`OYNG^Gayt6T1eYeMihE&Xmz>toq2**Li)P$q0 zVrXcNj9P=c+!`ERw!AxLdl;a*9MMm$=&si!^VABFIJIKl<@a|VCbl2%cfV5|F(X1l zDyAY~Xd6?L80~FLxSJuP*66Oc26wx~-Qn$h%<$@wY@na^a>>em+6#gM??ZQZN_)GV z(jKlyrsuqbCH9X>*qYF$iWw2u)G-yIO%+oUno%+IJV!>Y!EWr=*Bir+C>LTK_a9ODXFcNlXMNb}^ZGF;Y>B8LqAeL!q8JSk zH9#@$A}U1Ap<#m**&Bo}8{TdIO0bN2i0q9)q`R_Xsf)~)U=Y_TT5+;kEQ(no+LBR2 z1Y1Q^i8vZEYJfQIGKwN+(Xc^?%nibq4ez#pC0K+|WNr+TXun*BKlICGOu<~zdJLi- z88t-EQ$&@BA74fd5C>XD(cCN=HVAXMK?t(p-S)2p%c#>_ZVV~dE^YE^U0zxg4SJ0+ zMB4tAUyFwUOv#7=KsB$}2QJY_U>hd^##uH6ZF6AI)P1X_UizAcx!&h`*Tv>#WT7he z2%_AD?bzqae4!bF{X#P{&SDo;#~`Maj2a@ORS{JpNGqZShzU~ciz|tVkIZD0QNsq2 zaXAW7YYe)&IU(DjEqgeYzQZKfVIM4TqYzIc^X zHpoGlMI3$^6*H*p22{(aVS}7OdxOwsqp|IJ2^JyrnY#d=-(}&y-;|-UT*8XrLx^5v z)DS^g5mh37ZW%Q|9AOznWwU75Ae7|>VaCgVd6BlCLzZ9}bt=n^9aB>rnb&`IpuwlG zROkRjdWonZV&=)H62;e7L=8|RiinELn?u6}nR&fI*s?L)_OArXs7L1Y#*ldjZgnEs zZ*|hy{`w<_5S_`WA%e3asze+U88tv0eHlfavuM~Lbmj(O&Bk!szY;7$s53W)Gn{r? znm_CFZE3`{+tU2W*JprZ5N+jxkR@t}V5^8K5l2Ht4G_m&Mp5J}8a4=#xk31{;obJH z1Zn(mhLanENOxs(uAkb7M+gw^j}XLUy7qYpQCfaFYiV$ZAgzch5eG#^4G@Q4M#VfU zKJow!8{|CO8-z9+!)^ackj4)_bI0M+g;=?<`^=AfAkH86pvdd#O;pDq+LBR21Y1Q^ zi8vZEYJfQIGK#il(Xc^?%nibq4ez#pC0ItCGi+`QBHfj}8{b9dM{^L@9?jtl+g~Pq z45F?4lI108h+wOTDiKFRMhy_hT}H(WD?ai74IAVP+Z%*08{TdIN|45nGi+}RBAY8_ zWPXeaasC)pob&W+D#sw&%FjwJQ9}ej= zy+Qc0;obJH1k0#%YR&II$8V9mY$$Wj+Fo6Vi=tJZ^8^Ynvxr_~REgqqi>LvL5f)LQ zYz_?@q_W;1%-9%i`&R-e^HC3Fy)oooL)%A55$BJRQlvdlx)ON^QKXC-BG@XTO2pBS zQ3J$rmr)csi-rwCWNr|?Yz(*kE5Ra!B6DL9*&h4l4@dFXFWU9uVfeKFTWlT%5GErA zVEbZH-#Kq($ekDazzE5mW4I-Ny*V&w3b!(PcX7|cpPgRsy4bvoER5x5mPfFecKH!( zSoTM-?eFa?WADUSL>+^eTQX{hkXuDmiTK@>Q3J#*kx@*$Su|`AX_p&>5*yxa|4Oio zI@2yUhO~26c0%L7Ci3I*i1V6Mip+03ItI~}j2a?NlIt%`TX3{Q91Xdz0phsJsF*)x zH*g0-MhzR}{Mj3XFZ*2E{*_=E^_V|Ws=1`}#@5w30b+v4C}!L&8aBvN&pw8bVq>`NUkQ+Lt@xL}-+N;a z*@Q6Ta;XsFyw216__8tF_OArXsK?1nZw$ZRt@1ZY@NCsoyU}aIjH+E!+}Yx*w!!L1qtMsH`K{nx zRNP&9DX={maQ7L8*ewzNi~D;CUex4S3EodW{<__3k1E0YKY9t?5BCzhS6=({i|_c- z#hbqMNAJJ*)LVY<=Po|}7cV?}@#i0Y=UXp7{pNrDi;F);T<=K4!`+Ch1%zrAzQt1SOQz+e9y`PG3QC-zq zO_;MPrY6i-6;l%~jGJRn9&NvxFIj!CU%U-RsLZ3i{FbIpFy6BSW2a(hZ)r5{zOik!W#ngoMa!jllIgpO2e zHK8LFQxiH;F*TvR9MkLbE`8SQ^?4vT7(8@`WV>c)IPJ$9WF)Hj!s#60h;C!T=^UX` z6;to0B4KXZT1~>-Hl`+FZW~jRaN1*Fjy08a-QG2~-Rt%cbH~g*VrP7Kg~t9XEKsc( zFRXxN)Qi!7e(PZ;Jgw=MmyaPBebpNM&l338G4*~bLd&YPn$WU}sR=Esn3~Xkj)6dDj4TeOvxws*qQcEAqBa>d*jI_djc9Oy!i|U; zpl~Ci1}L=kD5PSm?ccqd4yq;QM5OYV6TW8?5jjnBpVzcDw0tHL`5=c zfM_pAAr+exUTw}belX^BWt3@G;8w@HhIrXv7GV@+gOoXsAhU=X?5jjHCL0_e8k122 zL}M~)fG9IZL1wE1BiBxeyu(XN}Wz8&6R93*EE50Wx!u&)x) znC#&I(U^=HAR3cV0~80z{*fWm$1n=u^9uqo>DsFRAbUgxRDVhfEO820_e&SOC=!BQRV8Qk$3$k z3DU%>*pNHSyII69yoieAnnlbz88z5fi6Xf~g98-FC87o>l1oGlP~=^Y!gP6TH6WGR zTQpZkGcP`)(s_ijlu=YVkD#=O8tkh?^d=h|AbOKg14M5!YJjLTM?q;`U6k5#B`-43 zOU6t&GGXe*)y`zjIL$OZ?9Ze-K|(T$86AZp7|m{h%(kk{Zw)qgaam$8IV zFE*pmd4!>qQ8YS_V6=!D?5jl7CL0_eYLig|L~Sx^fM_&F;hZL<_&JTN0E}6+-?@ZL zFF1=?brxY1typa}O}7d*9C_yvXI0tYU|%JoG1-j)qA?jYKr|+!1}J9L{*fWGNf}fD zQf59^f=n+uqs)1PQIt{4%y|TvMbuzlC89Ce-~iE>j2a*slTiainK=qFU5ZV>o|&=| zFfy~>`Gia_Jd4bnMNA1173Vv%h?yy)2Ky>eq@QSTfFk`w)Br{LiKqdJ%eq@QSTfFk`w)Br{LiKqdJ%4A0W!i`+`iD zVx=1{GcP-V^z-^N%CzW}%!Y8jGLJBdGKwut#osky@PR^f5aQ;hQfTWCG43d>f0X!M;kwERzim z5VK514G^o(U@#- zfM`rc4G@jVr~#tP90i#!#SD99=ITKh^NKafypoF962hTz9$^$^6lKmM$Sk4;`zjHQ z$p#0A#$?n0(U^=HAj-^9xZ?WB>weGC>(i($PsGsH5VXxBjF*g}ws{0?MbuzlC88VI z-~iE$j2a-ikx>IgZ8-`#*y;&6p~`x~m{9xuJ;?O(wU|(65k@gDkg+rv6RK!%h&Z9j zsKLHUL}Rjt14LsoYJg}=Mh#F*sQn{Drc1Gb@(ESe6o$-x^#Eh%XwNCw27APyR~ur(72EKh{qn<9ZTL4H|C_hV)k7V8JXafj?&{qaAA9u0 z#~y#>wcq*fyWe;H&2RbI+jhV9GjDt5;{W>cJKlHg-~Y#7dH;jI@~7Ya`l{OSLs4z` z(%r+mms~z;Yl!*`ecz+6eB~=&^VfDa{x&{Z6eFTs;$nv-dQe`t!!M!Bei5Z_w_h(} z`z`!T5Zf>AT$kJJrp8~%c4VvS=U?6S^*BUfAxm0%VIc}6uA7&K?A#8;F4vp4>uwC+ zYd4ne(7+hCk!-BnAq;d#CA9KSFqj9i2eT6ZO1i&KAurbxW7&>jNLRZcWiAO z7`xew-Q1zD2xWx-jk&jrwJkf#^m5KVr_Qc@%J|r>YMpRZ;(}@}T1qNuDeB~k+xW^7 zYN>B>lcs58m6UMZlEDr!mW#yIY5Y?*HjVjdaD(wr<6u(4Q0*YA4JIvtjoJxq#}47g zqFXdd3#rNXe(!kK7;~-n8*7i{<#RR{r|^3AH|CtG6Q&NaU|U<{+p88{Z28f0!_?aMW>JGQjXq{Xo%XnH4C0L1 zI|_HR#?nD)>ti;wWtEz=86<56Nt;2^_Lz~}nBBNBh}!xdA_rUPAnz#K(m~1`tJEMf zO9v^_BFeOgGA*La$Bt;s?#7KlkU8!lWVU4-`D5#+G@wR7A>b<*ZJ%k+NW3EW3JYJd zY>lSJRqJ57AzZOoMAH`0v_&-i*b#Nw-MBFbrpG9_zB4lpa%=+q)QwTMnFqSMEYD9-N2jX`ia z?jf9}lz4AVI<@75kU8Eb*;Nffr3Daa0W?|wiH{AykKOoV2HP&d6!@2%0<%7lId*Tj zrGp%6h-WVSYGsF*V>XI8W)XAju_Gpu-HjWAc;?c39yyj$;#alIv3KD2DagETL7@iI z-X?5jL7@gygEW{Lq`}l64W^G7(dJmLw>#t;gE*M>j)Lg6#2`hFOAKPnjIuTTq}8X@ z0bnpSfP<+4983-1VEUK<{Mh4o%m9OF?-JzS=Wb^j{{HQJBq>YKG^X}H#P_Ztj;RJ| zOf^VjszDl4A2VV;#bX`2Uj}xEd}9#D)V_yEwUiQlm}9Cf8I;WayC#k-tJ64cj_<- zW+Y=~H*O4~%)W<^nNlKMQ>G~cgt#m_qto$4d;{Vc&$hBGqv+H|(Wymr`q&Z0+16aC0l4+1+8YGzpN#~7o`1exO=LfhwA^O-LFS-ElxY!VT11(T9nqNGjT?g?bKJu` z)j`=*6an&7C#vRhK;0}#ct8K*DU}Jv)41_LJZ+~%3FEs5gWiCG?XyA7<;)(qCUan%;j>VSC^m2^-wVhaw0nT-txSHPnI!@qk zjD5lCh}f4m#(xp1wVl9O7Qpzvb`Z!55&61S$n!#rd?BBA%ty~v+B-gaj&V31GRIdT zpCm`U!M*Rk8~-EX0pn3-zIz= zHvc9cBhmx%<}o6)(;iFVw#LU&0P`)^aaNOiIlfmJu>IK1$m{;tj==Vc-oqbnckdkK z2F*K1ad1C#ksZ+9e#jnt1%k78F0Rl@{E89zlDj>=OAlKw-^~V?H~IA7)p(N+u-!D` z&W$&X0NYIn_J+9$ft~x>o!s+wImA65FNe@hdo{}8=`~=Uu0>sO9@u!g2AJn$s5`^= zZG3kCHvRRx+%$(L_RLttjK6t#TspNCv1x>f;V69B}zUKA@A= z7gV=@L*qDtq9W?-2mvm9t_V9G@q+9-^y$wmL+e9Z{p%mO6J0Nu!C9#*WbQ(NU8vN#iwQ zjyEQay)kG!UU;k%ovt53^2W;)!PQt9J4BrwL8ILkJ9iaPqm81*5uxX!Hzr%6#%skK zZ;Tqp#-On*InFyhLvgWY%Kj~Ym%K$bMNl@r({_kDJK{`f_K zAk9WV*$AF_(;HaNTmJp_#cajj7a#9>38Kc$?#hUHhtZZ1Gokk1kl7Z%#N1dQB-Eoq z$ThnyX22?9uGuK&+KAA((HoO3G1smYbG$L;+SnL!Ep^1HYTgSrcm6TZ<(+@o+|Gl? z5pCM2vm@Fx+fpB|A=xxhve^*?L~l&CB%9ZWIo_CT_Qqf{b?BF*@i!W1%-?9JasT;~ z;|Q*bsIwzzwA*6mt|Dr*QPemh^nCQjWJ}a|t(fDDQRCPcG`3&Un3UtMY0#LzrctA< zyPYDqDx%JgpwVuNox6&t(MD0@h|u%VQIjoEi9Jz#U@IMJA#$ysL7V3_!=?C8va$0K;28 zlvHeiqvX-QKh_LK$==NFStyQ?v%C$aiTn{m!Y}DBibIoi^8L)+XrricMCkeGjmeg%@mev*8>7atF=$L3@uO2_qj_c*HnR|wNjbi7 z^Lhx%im0-?zaE0Cs7l$$SEN+@p=fZim0_M@NKtmgVJeqd7pi3)^^*pUYh z?q&r7if2)KaFNSPqV_Hx9g6SWQx9(M1|8Hh93!j=JdEKuij?BrpdHuX*5WBVh@n4gEy|Zu}i1@aJM^>0E^Vd~4{}Z#|(uza3aw zBedjV4hj6hLJV`$3QGEme&6`R05l`;=VIvZT!j97Yw-82C-mpH1Al9TmR!ssVI3v@ zeKN;NROZP@|G@@Xc#P21VNC2t9>IBtT)Ech>RV0Z%58_PR)~xo#vBsl8Z5*aTjp9{ z$`u5!RC#@y|L(W>?|xRu@_iy)CUpOOA~3Iahp57j{-c_2ldGukn-TbTG4y{fLjS%s z`2W@u`uE#`|20C(F6NNHGV3glEcr@J<9aBjII1efi6k0@wXGGLkGzgE62GM1) zEy!F&lxCw25w5dRO6bpDY-ZJ#bUK#Z!fBTGO%a^hC^|hOqSK;5IDIsTPK#~9=_;Z) z8+C}_wAEblH*s5YNn>#>wi{s~GmHGDh;yuo(j_V4Wi6qTadYmXv{_(BHRv% z#a;2AaT!#b=lTiLS|fv?h+BV&9V@40=91z+C@O4ZE77rV9^vF0X4ge`#xs^&{i~sCpYcJ z3jixNVjn4D)futApT%yazM;HgOqt^L&`kcAT#e#AZ4E2 z7Bg=RDZNb8A%e^%g=eX=>PtF}MmFNI+;58D)JD9^bZ9(QLqA?qFi14UbmV!>| zFzIW5SOvY^(BYevHB9mvgS|YX}=A8 zJ;b5bL}{o!8`4l~8{|;?D3FF)yDbj2Ylz0i%JyB)A0o(XHJTi3XEmC1I#w8>~Cbb3Zar$vKs`e+cH7Tbc;RYY+%>JY(cO3{i1c1c(o+kdA~D;VSi+ApQxxgk!V z25AC48`1=78{`D~D3B&lyDd(jYly~d&mSVlY(38>KW!B!WX6wZyK59&^k;eJ1Z-=* zcM;HNn_34DSTu!8c(c2_O>h>l#*okxS1cg41NmSYdOL`*K*;2}cZH7S#dP8u6u?F5-wLO4Z` zX`?uZo)J-I(I5uVM}sJ{*cObfA{w(%hX^tyrLOCwv8Wl`4Wp3R3JEzQTErRAAkBzp zLz)q7gPai`1=5UYx5XK84bhnG`9p+D%1__P7g1z&AhnGzri8ZR2-<8EwVe@BThSo2 zJsL!9#kQbr71522Iz-69loBPLRW&D0Lb93@PGfg>iFh5becj?Nf~PH}sdWI4cCUEa zVz+^lkh1{TE1JS-i`_;6Ajd}R12{_`QOKFjY}KY_x^2#9ncx(05;Rdvz_Fqm8NDe3 zU5nBrXrnj@J_-bdnCa}cI0>#HW|?j95J6_E-Q=adt=*)tu~-m&pS7D)1erF9GS7%8 zvuF?<{Adtm7Tbc%RYYSp>JUL@N{Q3i%uKt?D~*k*4w+d_DM}a7_I4(|&b~j2yW46IVgfp6PL07toNo6 z+KO$#%_^cB8+C|~gRM-KIXJH924h({NMo@g=0@gm!~xYrX+S+2(tv6k~taGc4!|nOWIMnPV;E^$_R5?L#HsRHEpZ$YPIp?2k%Q^Q;H<1@nAh??nRJwU5ON$(drv!_bxNTAr+_iG8#jp1# z5Tt`|ts?O@oFaz9p=<3#aV&5JBY+FusAmu}*y#s>#Q z=+6=QbAIuBA64>)G6`@xbL$8i7FCC#*N9ffNdUb?eN1~FOUDYND2*-LyIWUB3fKUv}L18B$FmeDh-lLgCx@+*)&Kx4U*4}aDmiHGWoO1 zyd)EY?f6DqTqm{aKa4>Z(WymrS|B(r5S*?fh%2H>M5i{2PA#HSi|EuMI<<&SEuzyA z!D*AiD!!s5lV?7C%!bUY{?nbXr6~+&Y4|BC151LCqd&1~o`Cs6m=R4blv1kY-SWG=p{or>#(v zxB16HO&DwSn2(wC%s%A`!J&L~RyPTY)f176{tb5!@6}C89PPMQs*Qn?=-S5w%%F zZ5C16h>(LRMK2bamu$l6{_8DTbs*1Tw^LgpI<--BS|Faqng)4~wTOc50zup=qEj11 zrxwwvMRaNromxbv7SZX5;Iv8MPd8)9CLZ5vb(wgGssHw|14vH`AkqS8EC8N1Guwc| zRe;CPY*Ph*IvW9T7C@T?kY)jtSpZ=cK-UO%C7C=&wM%32jzCcuh`h5%V`;w;go(3N zYvO{#MsW%(5XVy6-~@5Nv{5C}SZbm)mKvn7)F6$e25BrcNMor%8cRDu-l-Ti&rz2b z$fW4_t!;DSnl*u(G%ccNi#Wg*2m@?^U~?V8W)W2)indV{Z4pIVM9~&ev_%wc5k-#( zqRVA6kz}nxL3(QZ-SOX`?i$ z+9=MPs|d!5ZIwuqs@aw%Rf9CC8l*|pAWf7=d3oO08nQmAkG44vjEa8fHDgp%mU~d!LCe`MGZxn zCiAWzJ@PIq0+n|LDenwY-dTh}G|s9v=Toivka@R?m|QlhM9Mo8rMxppd1sLF&LHKT zLCQOWly@B=?^;Jurd@bbS0;V%h0Lr7luUyp(;&&Th%zmr%vD5VHmXFDX`&?4AjvdH zG7XYUgCx@+$?OO+Q;J?(V3*&N%G2Ss=(1oV*uXlyUMNKtEGL4raw5W$wQQ zBN1g?eX%S@>2r>%2))CwkQ6-`_8%1pvQJY27W)ZbnL~RyP+lY{ZP0F;4lLKl$ z!Nq`@WtoE`0gox1ASJJmrQp)%7TWu`&OOpBPA7BMqd5!25`l}MRsqLi5iDKiaH zW*Vf-G)S3gkTSEQJk?=wXFJvL#hsiTdmJ(|%QQ7R8l>6LAkB^zadxzbv*Rj)vGJGo z-Oe*JEz<00wx!w8AkB^jX?8S7v!g+p9Szd#*b!v5+FAaD^|E9RC7jKlVT)A z_SpE7B4GQ8iu*SHM1@WK=|y&2-cFCyC$2ONWI1+;-^Cky#AS^AEQZrz~Y3 z?8mT#9Tx9JF1@zR(D&L5d@onDZF7INd05sie=9`(TnzoK5Lr5mnGyOsjFF2&A{U3` zv>3W@t_llg(c*YiItA4|u7hoBq}a{rMR9TO+jOVrB&X zTnt?}LVu2&76TW)HTd&nKL(kHb7wqfcS>11y^fE$!z<2~>2;0JtB--#H9~tXW=7!E z#n6!>^yvHtgI7=XWBN7Aepx%n3*qN=EmvP?x%xuB3oNf=D0Tl~^M-KdvWn=n zh=SNvM1eMHiXhiU(Ue8hYtgAuuvIh&#Rb}r!a4rCdB*=TOT4m3S}ECY651U#*h^c9!lxGGhISo2Bin(SRWP)0>AB797R@Ba`OIy@VnQ;)i z>ki1wTL>IrR}p0vQINTcXv{`U5oFpZYOyFjJa-hd*lqDFwm|z)klF6*@LGJluLEPP zNSg1p+18OXgv=aEtB0r(Nv3U(UA2Z}%tTEQWtu3-G)OWHIyH(~Y=g+mqI>tFAhX?v z<2|wQJ{$(!arI%%mD>c(n{iCg`0ZtryT_2`N*e{IMS~@xI2$!ZoGa~auuRP$I(?(H? zMbu)^sZr2UG>8N((0&wTwmaIKI>$TO7&r5dHf4_QdKwUh)Kx^8MHFPNA{w(%Qv{hd zidrn9Op8v9f|jB|$Slx)6b95U^DOqOJd4fW8f35kpay#l(cT)8JsU-PYe%v(cBFB3II z%sdmt0Wlsp+kHcKhpfyq=+r3YkZq7BrWWl-L1ueSj+r+;Cx_k7=j14Jf7SMepk) zTSatZqoxSjY!n4pL~RzG8U+DGgS=+g-fC$*zaNDhY)_o>7WVkWDRQtqaf;uVVwB#D zE)7BEDx%CH3Nlv_joGLvf=nAlEf!IxMW;qVOVJ=?7HB^TGMf}$>CXzxnV{oqU7(?(H?MU-jLsZr2UGzggm+K+CY0;@s&{8xAnFZR9g3RM(=ad=VHi@JX zi`d#K($r}iQX~r`;AGoH0m4r$HJz4Vp$lfZcg!TY>hYFm$#G zGM=c86=Zql!qQDX*|v8MLIj!Pefc4(M3h-XLFOu=F&i~SkZGf+#Ug64=+r1^DH=o$ z7HB^zPii0~_Au|sjTL{;(klLV3AvTo5RAn^Y%=p0(mZIRI1jEN8ne4GMVtriwm1(O zM45J5lxfj43R-NG=0UR?_^E0eg#k56$yJLPolLPRtc>Sx;)Cc9m0rfwn?E&P^=eI) zUcN@v=a*Sq9`DqC_!IlZnM<{pAFFCHfB5$=KJ#JLVt(oBC;#y6@4x+TKl_Du-TTN_ zU;K%Su@>`>wzZh3S-6OOf9(TS`CYV{g^R2YL;YXub9B#Z9^Bo1F;Ryy)@QB|eqmi} zHNjIUeh>JSbgi96sw0hu2Fg8nG=vE;9x2Sh!c)y$Ri8wfV*GzeasC^_d!q zTx%SO4vB`8a}l_3M@oN>lW0KeN6Pw4{M&0W<;v9u($xx)k;9mpFxTP?a+B?-6P2rT z5xR1%J#HdbecxD%iGO?DC0&g#RoT{Et`Qn>F*Ttp7eiO)BQWAyd)$Ps#(l%M$hyn@ z<%B`-0)*E;cp@t@@3*<`epcDyJtj{V?=gXSy|YABnDie#y~&iZK68c8vTLm-^zUNm z|9k|NeQS@K(Eqq^uuKd4^_j3Z)M8R#Q4xsIb`{Z-jhY}@v{BS}Rs>%~gEs=v=eUPB z8IM{_TwuM7xAZ&}S$prh@5cX#|0mDXhYC$4<@SnxQ--;Qlzk>@f|QviicZHhcyV>u z$`yIjXElmBXrpcfQjYdLgv=&oQlY6Cc6*<6Q=z$rXe|ES4cX{>RTt(QXON~@gDBHB zcq)RiqQM)1G~xE%Ko0&qb1-ZBq^(bX=;ci<<{FYV6E#87W}@WgjEHV*gEs<6Ti-+E zV5`OC?Z;Uyrsm4+?UPNx6ct7GM6|=z!(p+hx=+riNDuTG8!5e`zSN7h3(^iYg zan;shQs(#;6C~)S;Ia?Z*FiL98=N4@v{5v6Rs>^3gEsDyDi+e zi}$Z?#^~G)IXxqyINRWjKyupm5Kgl<^cGIL>@Z};Zx*{g#6$D*@=V;9wGnW&29TCE z0s_wliUY)5*J2yrdH`MH#&>U1bV;T+2z(MMWVd%~eF3Hfn+>+D1|ISrKd&4c-Vu(c>QC z%5tpbm~C)^DAPt!=2;Pp6%F19 zM496rLT2`Sq-#!{w%U_2i<(2snX8D#Y}5o%rj4S^vmzKP8oUvRGRHl{>FHSQNp1UU zV{NKE`>k5!;4wruw!sOaHXB84XGL&RGmHv&=SxQCFLJs&p+Qs%7MlTPaO2zU@|;i79dw(U*;yAgZ)cRC!he zUqypA0#W6-he*$q61AZz)7E-w;_S~9U_c#eJ@4&3vT+R2n2nksO`K*oG;y90!I<5{ z7|Ct|(!|*h88Wlyqc${Uj%#MYSQd0@;_PudqzBH|5RFCb=B|8hYl1X!nkY@2XGAb& z_mDClJJQ724;d$~W38tK)a_N`x2D#U+KTE%{N`wp22`_$ry`D;w!s^LG@$l9L=Ltk zhB=^qUQ5%B6`+*4|01_dndkQ8^%$Zt+u#H-2W=FMofW}Y(cq0hlsWDpWMk}vcq)RMqQM)1V&C@m5ZZ=X&p7aS#yqM;dugYZ4c;xp*{Vxa z4H|1-M98fu?R-B=JHvc@5Q(l@&-h6%K8U}27`2|?|NQq|eQ&J0`TXa+AyfcLSB}t?BXs2nT%C*1m1~Wz9HA>m=*kng@&v9{35@udeS-YM z6dkt|Pg;0WIoL}jRPw1DJS57&L!umXggJN~q8vPIO*wc-l!J#vIp_#;&=Ka~8eyin zn0=zc&R7XOy}r4Pt8dP{+!S^qF~=H0TrlM#n>1(<6g zX^^thAf=~8%ukD$psR?9XruNKoVLPF-qEm2Z&GGl&+g7#Av5bRHFX-Kxziv`o)&TT zJR8ykY8uoGYLKQ-gEWU)#7Wd5&Z4Ub#%#~)HdAj9GE+)Cj+D2j?9!W(xxJy7m4%XN zkYpMpnHEvz*^p$K1|`!V$uvkZEuu_|D03B2rj6Q1xXB04P)oSqTUscn!>EuvG4=(Ip^S|B)GM-W#;?ISpCQkbAc zVdvozMp@X2^c+vIb`_rxX#q4A;BY~r+y>^-wFv055s+sA)L8&=1vp&7sO@eUQ36yv z^^?(EC*nMH1iQjcK2Tj0c5*D;4$oObsIk-_jim-@EVYPZ>DiFRQq!QuQiC*>8l)HX<^7E!51R9YY?EfAEhBX}#K_7Rju2`AiS$so0juesPEYO{#iETXmo zLE9M-wb=%#%_3^Eh}sGSZ3Tk1bp$s>)IM5Ru2YG)&G4)xR3aLrL^Mc=Xb}_fY)FY{ z8dM@0q(n4GiD(fM(IO_|Dq^`k_j>~moKy3w{kh#CaQX-AD+pQ(i zSZk2RT8lW=o(*ZNH4SR4HArKvK^ki<;#g}D$J$i{W47mcn{`B(BLDOvr^qY~)GfK~ zCKFNHSn3Pet)P&%vP9K@ME%1cQ{Z|5JEuk`V`324y%rp%uGYwK^8l=p$h?!{- zGjkO&{cO}eg3Q+Q%*>*|6Ees53L!JImXIlxY!V76>v6 z1exmy#)_zY1er}rS>TC#Ls_bcsdIctXa``_0{FB5HVc3|Z~cq7+XipRtpHfGO~IiB zFlYh%6#%#7>|T`-CBU6G+te0d-257sLC)eop3&IlA(?6xG1V+$suc*Sc1FZhvkfxU zEMlrz#8fK~QmsHpwRMDiDx&rgQZ0R{Fuo|(taNqcjOx z#7XdMh%)VNXc9Er(j;h*CP9lh30lNSa23Ir-FaSBj0iHDlxeXhWZLpX%Cv|wEuzc< zLFO3|W!eTQ(;~{Wh%yTVnFWH(bp)A3)INet6};x@>Bm&{w=Lo18BHIdk(pU7NSR}0 z_6|{|MU+_}p3#_X@tSfqiZX2!Wm-g;7Exw_ct&G)o@X?x2*!$S?IXx+Ql=%GkQsTs z88EY2kTNZzOp7S9K#+MxM47fh%Cv|wEuzc2))CwkQTr%Qbx@=eMMXKF+T}Va z)7CR;cHE8=S>37G(ICx^7IAhw8`A7(8r1A)kY-1NG&@?v+0i1-j;jd9Y|lqx?jgv` zo{s@IWxkA(D`e(S*cs)Rp*tQ;S5aovc*X}&OwcPmu{c<)2HF?##0OElsY=f~4^W8} zi{m}~$E@`1rJqZMou3#BJ72!_@TZ@9_NP(UIWGqtSMmG;i;-AZb~_6oF0#A~D?;9X z;0p&?goc0b<=^sPp3Gv3%T`QrnFOnuzTv@Tk|!H^G6@T7p0b5CDA2$vYR?p_sDZyR zJMdPz!;pZ;8e&)cl+AmM9u%x7gUf z<1IG8cFTr+XWq$$zsXxRsM5l6UgKMy0NaHqH$7g6V)x#2H+L^jiS=;$cuI^0@)S!~ zvg2Da0NYy^*!uX^h1@hJ2JY85F<{f1Aq68ytn#2CB?goD;MO>#v>fXLhDMN|)Ci)| z@DJ^;3mo<4*RK`-X_Lf)NAkSU(P@ ztt`RzQF?6~{;;)N6pi|cxovxDVNa@tIMyDfz zQ&_<12%PrQE#%}_Qx1RQLt3zelP>yOCGh8Ca8zrA{#*?Gt&!-RVh`xgwMKudq&iai zbFI;iEQC$B~{Gp-`ejW!C07M-&DvJBfQ zk)}rzrTK9U(WKoCsg9Q9P72Bzq=G z_STT>nJC#aNcL7ydB{4+F%8OIN4U^x7w^0Z8!N@}n|f9r^EdT=HnRgz8OOHEn(KhW z+LYWz+!?BxDgXr9jYHoWfI1ryA9?_7Hev$G<2WoL$}WMfaT`dr?GnGpvGG0J7`5`A z3Uh27A;*d+eBo9RbIe9D$5s(@%tk>~v4_kt8#P7cA?u{GC(^FxnPcO&kYgz&p0&xF zEaQzXz*n%K`ZEo_qStS1>%(Ds|BW8)&KR!z*AYw?Q8?^XMAJ5krdJV7+bEj0h&tC% zdB`+v8>HzGAwgf|8r*qfQsp4!H3AqU%yWZrEomiXmp+fRL9EQ^jc+O8swwl)e!7Qc<`X4YX_CDLeX zqBPpBA&s^sN~5ho8d6u$kkLooPI2p~dXQ*5SIJ>h= z;UsPaK%k9)zBK@KHUi=-fVNdo93X_*rsBg5fUNBHsIJKbENa1-)7t?$f0ff{x{5fO z+9(b!Ye=K1iPC7ghBTU*D2=8DX*69$$*Hm6&!qg3N|^eR$oUN-@N? z3UP?Gb;=aIjv%^-!cngxindV{y^1K>Mp3jy6upkhL#AlkAVrS|r>@&Yev$6|i-5uO zbGO^w(^Y^$$To$sW(9CCH4z+4R{#f76T!jM01l>Wpg2Gdrlu(lrXB1mzu zHZE*{98|IKNeGEnG>C8ADq^D9C??t}Vxrk7CYnV|v~^S-G84@<$V3|vM%0uNPf+Fb zI6f`{W2z{Q4vq)aHiL8`jhd^7qo$4G5VD3eYMLmGnrld-ris$1X^=+ERa73bMorV8 zM$L{Ovq|B(PEi4l%#7%b%vR>@9^1}W1vI6)dv zO@kUx4bp(RiYCX!smwH}0ktFK;P2eYr<}jU^5It1jW)&=Vqq^46|E!KE23~HtBCe& z6cw!^+Otu#XA$kKqwku0 zMo`lrwb=$INF%6eP$Q^88bMdlji7y7klA{ETJ?=4qi|xA zepyXHnd^uHs%;QQRy0^54XCC;4XA5K1FDJAfNGEi)KxS&uCnJfp!RKHK;71NBY6My zL#@t*r=;5|yW@xhs*S=i6x%A122|6aM$I*(0o6olKs87M>MEKXSK0F#Q2Vx!gV(9= zMw3xfxyiw-OJWYLBgibGaAd29GHn!Pt|H2`QIu&BWv-+0ka<#L8>Gw;L1rr!Gu26--+l714wb&MHG%~6;Y;*qRdr9nKp_tEuzeIR30*A+6F0e zM3^0iV$3*%NW>`TSE*%mQhxB{!&Yh;T@xR~PODt=GE2ha*N}%FRxzel@@>VK@f%@; zVBy}SV$6Rw7GwV6t%twz+_S%u#h71z=X)+*`oue5yz}9&{=#SOzwZm5{fD1VT9qN&{yECR_}^U~7cm zJQq_Fe(ziizjls<;&QJm!f&c;?KD#LTRdmGw{I{93r{`RFVNf;V``e{ztq5AFUE9> z-^P!7u{B3D+8?%76Iybu)kKrP;dV3!91{9-+o=gHxtP;P)o=7SZU_E2V{kE1SB%MW zqI)eT@8tr`z2q`_Vw01{2=>jz(A*lKITuqCI(0GeS-EfgN$c=P>D0AGbLS&fztQQq z9XM@s!?YNaIXRXAUZ)rn`{82fZ;jBBi>V3yxfuF$gqGF_{kfRaNY!ujH*V+h^-n}e zY|PDR@2ch9Rlc7DZ)|z}gL_HtH*!A--c0gz@n#Z?#rtKPv>$W-CVR&+PDl7HJS58A z6`~wHjH!vT_b`U`-SH?#4`XVg>^+P*ja2=n?Csk@_QFCGx}@b^#tHI5kO%F}H7_*( z!_vT%8oRiYifg#zrJdM48%3|Hi2iKU1ktUHqFjsUa23VhSBHEmD*KR*$34V_T1tsR zg#1;{y!{8_HdUS5J1NHz_s2xZ=^Bza6E#6{YNF)SAURz_5@(`LMP(n#Y2Ox{rj&TT zKAqZvPb6p@_%1!KQ`d<{9Zdu@t^s(m5hZ{=8xbFR0ChH^1aM;`P60(kBy?!RYY+%YJ%w0M$xH7bh?Tt&PJVz%08si zaa+(fmV4@4b$kDLQ`2b?HaWi09kMfu+Ex+W*r*AjHXB847E#+Oq8l4^Dk}Ss+Qw}` z+i$Z517n+?S?2nD2(^W)qHfn>i$q%0AS%+V>DSn7-oX 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 e)hLf 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°}c$aml6R&mu=GT2wd#!tHF@9)_um5WWy-Mt&$+dz$ zOX`;j`gomr`)v{`=)1SLf5oK*^FP`b%>S>~?|tT}CqJWt`QQJA_x#q)fBOrc|H3|Mw?f@x$N!Z$EUqFPQ%~93a!T>e|@oEelC0Tx!lp`GDn}w z8+|To^tqhT=Q2j0%NKnvTlBeH(dRNnpUV?{E=%;e9MR`8)PJ7E@h*{Fi{taf?swjL zZGGGO6BmzNy!!n6HB&}8bj*~Gf6MjrvsO{PzWm*p7!}!@nN+6`XCe`2A`xdI5oaP1 zXCe`2A`xdI5oaP1XCe`2A`xdI5oaP1XCe`2A`xd|Pn3y1kssH}YbL#OA$~V3edSUv zUBCSO`)}R-^4~81ckPNr@;)m1Uz4&(-bV!y9~DG=R1on|LBvM|5g!#qd{hweQ9;B< z1rZ+=M0`{b@lipo~80H1gP_{t;{pw!`??j8c9f$L$60KEUJJAq1(GWS&5INBhInfX~(GWS&5INBhInfX~ z(GWS&5INBhInfX~(GWS&dZb$*OF6us;>2?JJnd~|wsQD9?*YmaAD}$*0m@S!pgi{h z%99_UJo^F4(;uKb{{hMqAfP-00?Jb$pgacx%99|VJPWQ!GM5*^TjuD6@OIiSL!<<4 zmDhHP$W9U2DIz;XWT%Mi6p@`GvQtEMipWk8*(oABMP#Rl>=com_DD{ba(Fw%a`>EW zw|A9WIegBxfO574l(Q|MoNWQ+Yzru7TR=J60?OGIP|mi1a<&DOvn`;UZ2{$M3n*t> zKsnp4NGHj@96ob!eQHfP*vjEE2LWXc0?Hf&lsO0}a}ZGGAfU`aK$(MpG6w-=4g$&? z1e7@lD02`{<{+TV!4)Y7o0NEDzFm*P3*jZMRl?h8Uypr2c8bVO5!opsJ4Ixti0l-R zog%VRM0SeEP7&EDB0EK7r-_6q3Mgk#Kskc~${7?;&Y&w&DmE!mIp0zdUkz`WeLc2h zwjz1UL}ZzWEEADsBCgGvR*!9Mh^!5fwIQ-LMAnAL+7MYAB5OlrZHTN5k+mVRHbmBj$l4HDTaPrYE=BS# zR~Z$_+bLFY+bJSDMP#Rl>=comBC=CNc8bVO5!opsJ4Ixti0l-Rog%VRM0VOEIbB{7 z@4Kk@lK4!}^${l}Xe*u11O=1{3Mdm4P$np#Oi)0Xpnx(#0cC;$$^-?J2?{6^6i_B8 zpiEFenV^6&L02T1O-j_$x9c7(owv;XtxJ;GsmzNg9y_%GN^O8r8=%w%D767f zZGci6pwtE^wE;?PfKnTv)CMTE0ZMIvQrn7@gG-UTuXyl<@;RZd_1KcxO0{!B1(Xvi zpqx+v<%9|-CsaT=p#sVY6;Mv7fO0|wloKkToKOMfgbFApR6seQ0?G+>MUuJH&-?87 zyxv%&%Roa$`$TAUGCL+s3WSNL86Om;ivP?vliO4b$StcUOL}ZzW zEEADs_DI@(`$n_(`5PC9l6Tqbm!LiXxkDgx2;>cctRavy1Tuy|z7WV30=YsUQwZb< zfh-}ABLp&pKz@24JAZb=6=ix6RL+p=%&Hl()n4Ze2`FbsKsiGK${7++&X9m|h6I!| zB%quj0p$z{C}&7OIYR=<84^&=kbrWA1e7!6iZnwuDe+u<`~H9h@s`=wUQ1>xh__5c zmWjwR5m_c8%S2?Eh%6J4Wg@anM3#xjG7(uOBFjW%nTRa2N0Qm3#Mk{M^ZmKHL0=|s znSDjJWVSMS%S2?Eh%6J4Wg@anM3#xjG7(uOBFjW%nTRYCk!2#XOhlH6$TE8*nN3PA zRq=BvvoD^v%;VKCTb;ILBC*p;qYTq{BDq6L@Wg@anM3#xjG7(uOBFjW%nTRYCk!2#X zOhlH6$TAUGCL+s3WSKqEb?Y;awCmPZjBRcG!WYWHhO7;dwIQ-LMAnAL+7MYAB5Olr zZHTN5k+mVRHbmBj$l4HD8zO7#k#ev}iCMPgAQsPCW?v8_nXOLSG7(uOBFjW%nTRYC zk!2#XOhlH6$TAUGCL+s3WSNL86Om;ivdkVS2m9C0XAZ7kQ>3j`WM>Wn${YlgIS43o z5K!hIpv*x)nS+2b2LWXc0?Hf&lsO0}a}ZGGAfU`aK$(LpQVuR<^1fAyW%8DZ#oCsM z$TAUGCL+s3WSNL86Om;ivP?vliO4b$StcUOL}ZzWEEADs_DC|9wF~^+v48!17SBiN zYuwt2Wd-tG^6}jyK8UX^Yc;)IOB)wPYc+kA*DuxdH4a(=C=x$EtJU2nx6}~Kh>YOTNkZ6+xPcs18<0~DOwwN!&0~Q zhKsvj{i-Nby?ULZTJp;lAFJ^z@^;3fF$=bLD(%=^p#`Bwk%kzM~^WQ%YDA*qQivzbszT^7x zv%n=!ohfj6|B83$XEQLKZ4z9bm2s=^tc-R#UQp1+$N&80Sqoei+4d0hi)`ypfAmKC zo8^~`IHY1E`d>1%<@R$$4F%dkp&LQ~oEG z3R6V;@)8T;w1|G`DZ%Ax%+2*zV`?YP?)sbMY~iu%&lZBq?Bmw^+2>8Kdt+O=BW%T? zaBZDzVj_wUBK_9sNX7QiL(MIZ#>=g*x5*~1HSYa$e-JZMJDj@tDJQI$pK>~Sr>zSi=1{*ONWLpOf@3xDu|v*+Lbo4wWJ&OEQU-ebr`1jh|xOH?@vz4a=y(mGG$XR<|x0aHK zr|zPiC+=Q9eDBPqK4Kl>`yg6fhFXjCFNxM*IbBTMgNk^a8tOrO^(*=o#pKf@vZsjI zll5$JhG&yp(8K;>4>tb15}!tVmT0di#{QVdACsyFUTTPA>K;@i+9`Tqf3zL@ncI1uPkpcdE_V26h^5=3z+m$K$T%Kk= zkH5vgKmJ^PH2dqf4_z#+i+$Ys>YKV)8eM@Y`E;G?(_UURi+gpC+l^m!b&s2`J&Qx* zii>>s;OKw!*1@66Y9Ac4L;@(r2L}_WVWNYBNgY%7pdvmvXgfYQi1^@`BR&KuW>37x zhyMF?dn}}_+Tm$-y5y%*VGNRmg0c;GmVPgPnYz9~i zL>vQsUF#R@@T?aDcetX_&DdNUA|{=Aq{#)Nq^Crh#!ebF=thY=|6w;g0V47YIE!Sa z*yTOaH|XWIjPHi@w`F9kEdY_Hu&vC7@*Fmce5Qb?*xl%UpF4+c_}fB(?k{T4jS}U_ z3_71@GeCJdn?p8+ySzur!SyzphOOO3v%UVO=mWC7d8DzyDBCNLQn5g?SD-=Epc^H! zJ=~V>WSO(b#xQD+B(wR7N0Hlo&)>?s%>9BLmKnFOHz&z(8ijS^WVZp$(eS>`OVF^t+H$y|PHaAx*DHfYj~pB0pa?Pmo$?VqnaAUmB$ z8ZnHr(*ns(f#kG6gQ!6_N@S8bl4cQ6g)@ZCM*4Ynw%OgHd}VZGYozYd^(VTmJy8vaKO&n@1WHjIy=@ zNkD<5tw4jQK{rZdZMZFKLu75U$ZjxdkEHFAA4%n{{YYx1VQ-U(`TJ>%VRT9F^ch> zZsWN{4UvWlqjFk>2K~+Pq)~%zlqjcF=wVK)fO1-$LpFxHyhoC`jC}bJg2c52AndeX zU-y74ZXRjGFv?B~q*N=A4y^(Wq6Xb4k)7hU>=com&LWG$s6Em>)bJJeTU&bKavfF~ zi$H90u&oSXmGejgg;7>nAW12ZR2FCuHRwi(tP;0nm58iz7TFg@?UB-RS+2u3#BsR} z%ZyjjY-F~TAuMwqX{a#DG7BUv1(M7H4Wb6!D3N92wk#8oWzHfS!>B!y%w@R_%f#h6 zEHhS!+{kRtds*f@(okWPWfn+U3M82Y8bl4cQ6kI4ZCNHF%bZ0vhEaQ@3)_CVj?}hY z6rimPk=kaF2L+;18zKupWNnBBQG;%jD7C@1QX8PuHiyy;MD3Asut|w$58Bxgm+Qz0 zbzK=k7dq{E%A8PVk%sE5y(Q+I*S_}G{YADlm=h{A=!@5rMh&`AqMT5nhdH4F$_aH2 z*%vKJmdKCH?ir~P9c2Oy)fK)a@1D3G)jXb?5%Mv1Hq zw`FaJtZf$A4My#ea>xxH1|YTHugEb>r6RLVqTEr=`=(I9Hj zjS{6y*jCB}lrrZ~8iS}k(%G?Jn8VuQ^$we}W2-u}wt1vM!6<7hkOUM++6pv?8g!#X z)`r`%HbmAoi|huY_DI^E^Li?6oegX2pB2}6upw)kM;a82vbF+AK!K#KK!d13H%eq} zxGif#WNov^ZZK+(G<7y9@taipV(~H6w@GHdFo$K{Jo-hyRrOltJkn5Mlw}r3S_&kY z1sX&Rx=|v_#BEt7BFmgbHil7qB$-W0Jh0ki_NCTJzgB8(nf)3N2V|M^NJE8DmRTTa zDUf6qXb?5%Mu{vFw`G}#EOQpw7)I@pa_~!!Tx1b^6t_pgS#)VFan}bCmnH78C-Fgi zImxQ{^R{Y7JimJH-B}gyBKTLXi*#gFd=$Zd`|H2$;#*@)j+>Vj!TjRD70#O5ewMC9~zF?HW6 zVyDyto0}tc+Q+CM_Pn{e-CV8p?ftvfVaL& z5xeSR*-6=<07J&*l#E8 z%@KP&RZQKtiloD`gyV|O<(V09Ds5#F60i^Hdg`qg%BeOAWs-E z0Q%h$wsAiZWv>b#LAY`GnFF%ZN9e*j);w!BEUvYM13sjs#*|8ZnWiK(U_0xL$g2SD z6-E@mLkbZCpaD8jMA@qX*bZ#mc4mO>tP#>eW$0^e>D%h241E@9Qs@`y?iwtSJQPtw zq=^o!A+Nc&zymB>yp zYKUa#q)~$&mdH-A8+JO2?6i;4y!xwtTyax|zJ9Zmo2BB}x8G)wb}zE-a2eAzSR%W@ zs3DTJlSU1CSR!k~ZdltavbH`-so12%GkNWn?ywAfn}eG&^jV~x?iXH{%%#B+*%(F* zkz}4UYS6bn@4hb(x^cXOJt|m4LhAh zcG^d2Uj4f&L$@|8ytcM^ByA^+lAEHz5?LEYS=%hKwmwR!*wzM!0+vc~oAUGZ2J#`w=guNZhvY1BK4H`lDb-FIRd(JdNZgiF4UtoA7D?K6zm#fA z`MK}T4a?8B#x~{Wvq<9+&#WBMR)@gSV2NxDqlQQ_PZ~AoVTmjgyJ4BL$TIsV$4WQVzC}k92OQ zyZ&u2lG)0cEwf)x{eUcU9%-kGs1jwOZExna9uARn@Ptu=9+oI`5W0~$IEOL^*C^#+ zyq!mGTJd*kgCgnFHjBJ_7*(Ql15ra{Z6}Nx^sq#!4Z4xq=1^)|qoj=r&9`6sHihQ1 zNV^wfy*cA84VK7mFlvaT?W9qI9+t@3up8Dki>$4W($u*Wn)~wQ@4C=j_iC`*I(j_~ zbg{nPo!1weKhqh~KBa~|ULi5<*CNsK1^LeC0vWO~?Mzx3< zBBdH`>wcpKJuFdlpz4NG4SP61PPINtskRiF`}d~5^Fs5yFS@;kZAI-K`~KM;oscg_ z8pV@F$ym`~i7XSNY-|=;W*;S)%iI2Zle>T0pN6WHGw1h*ZDw}m=f`UlT#mHUMO29_ z6QhPmGEW*c=wXR06T4xVv&b_0D9LOiAKPl*ABN@U+icmCpU)!g^sS@s@JoXwvN4Pr zBFQ{y)S!nYvP|rTWzHhY?4xvcjCX7*2V3ECet%f60}d#^Kg=TS-tmHVOXYfr>;|KT zNZL*sHRxf9tPQ(iZL`SQ`Y36OccRKos~b;k>y6$6N^P^qyN6LFN;eQSMAmk~s6h`) zl-i&hscjCWwlzxH{;o>O^EdkKth>B4SJ$FgUu|s#lC}a38XQE)P0?V9tPP{AZ5CNu zAEh(iGElyJ>EAA_MC%I8kC)W#3eD${cDjfvk&R*05J}5PqXs=Jk!4~xEOQoFW*?;- z+?SLezged%DW6B$y&|eac7stvByA^+8uYM4)`s1%wpnCteU!9)*2_A)?8W1rIVZ$G>V_x3nQ&sLUhnUJ+HI zoC%>DL*z;Fgi(VYmMAAl=tfSGb0{atHA>pvb*roSSA}L=fUY(Fei`natoe^Gi0v;9 zHUE(_?cQNm^RExC#UcRjI9>!GYX0B!`Zr$ucTw~I=%qFPKe5*Q|7Yj_<-I?B>d8NS z>h=HN#*csIlfQlKmwxx1-+Sx%fBG};y7=)=zx~yJ^^>1@@A*&u@qhfI>wU@pZ){8c zRUo&%yFS*5jiZU)+a5ob734`iNw4|7&)|{USG`*N?|KBYNld&81-V zW-ClpLAi}aS@~hY(VmLfC=nYa;t?ehk5@;piP$KSSWTsf$rI|@YZ-lHF-mRad3KG+ z{j^bvi30p$`+5GQ)~fxqQQD4;_GB&hf7MaBzw&V{?YF(Ys9bxc(tg_`VtYhvkBIFN zu{|QT$HeD}^{bZboiFw%BIoTjBKOnwD8}}PF}?w&Vtd&$Rj0RS8AOV72ZI-A%i5gR39qeN_!h>a4lQ6@6l6B)f&jP{@B-q+TWA3zb9#b zPtyKMaFGD1>VL!0@4;>L0GUD{Q3&J-fixkIB?OXG0R4iz&UjCJ<74V>xoH{d|NgLh z5`;j05J(RK*+C#V2;`;*y7SZqJ*MCGTg&ME^)|L%#A}z@!T~l0z{UW0*Z|lV02>2f zV+dqy1u_=hEjMjr>)#)C&&B}Q7yugsU}FGm41kTTK;NADWdl6p9WMslD%3sYA@ZDu zNaLfg`fK{?%k|Qr&hCgj?IH5KS0GJ%J<_1Zwg#Q>)Yr!x`scY1ktaVyp8XJc`a|UT z50NLp9!c6qT$8+%n`?}t==|`dLHp2@0hK2;&IG(?tGAW7?yq($ckov^e% z=CH$-hRD(oSsEfsLu6@)EUib97Rvx=ihM!qraW`ghotjbfCI_DCwT zhwW^K@2$7W<5$D9+H|W#WR-|KiioTdkyRqH$^uDckEAj>Kj?&2_AwgXw!>D5$SM(8 zB_gXtWR-}lvPV+6EEnJkb6hUKDr2}dS-DiE>;4cTt3>2cL}ZnStP)XtSarkqX!N1RJI+qN<>zP$SM(8B_gXtWR*SAJp1pxOjZAOdb{f@(6uY=#q~}@M0SVB z!-mN25ZN7~*okmgAf;iClnDnYk99zqaDX!509o4c+f0`pN?MO3EzqD7nQ*YBOgKQ9aDX!50A<1f z%7g=y3AZ9i>)%XoX~!>zX?5m#HM)IdvLQ>uD32E+OG9L7h%Bu@lGYDI$@Q4j8rarrHn;nm58hokyRqH zN<>!KBdKgXjO*5xv7@qo8I3+2Qd2|H)&r1F1hR=h!-zm85y&F~SuB9=t*zbFy|wlR z_o5;B>;L|+drCwEa)%&3WFT)CA!`WatOtkp(EDB}zK7na7ejear6Vz4c^*e1BBvfA zAN7dh!>U22UJ>Q56xc&w;jBm^qVt1JIQ4K#d4;n!*NugK`o$;-ERa(Vqnvt(oO(S{ z>NO?NU`st*2EZz#;f=~xwV&5LfUFXuJc@{-cd^rmtP)XP_dqxN?XZuM%IN%{6IR*B z$XDB8t3;I7J9909hJ#BJ&TTGXDTs8a9~uw?--d7A4L< zTmm5TZ+!?vd)8|2V>|6Q0F?O$C=Y!=nSX#X{{UtFA@cW*6-i}ue$a``zcuEt!ZzhgN@XX^1Qh zk;e;>r6IC3M3z<{e~VbJJtQqUKj?&|VRM#-$kGs58X`+WWNC;jtw+kgWk~>Em5!DK zkkP(2T|?4p?Bx@IY$DJwB9KW0@`ykd3$U%q*SCo1Zn)yS0`-8X~jSu2k`?cI(A4IXvTJEp<&#-^*2eOzytNWvv|Jz^xZ5Q8q zrDFb{T#Na??)<;H_r<54{Nhuu|Ee2*8~^uc`JcX;|5t9e7ZLlF;Md##f7V`OJbD?t zDi>V=&eg}NhszgNua%L0G5J~g=)2+N=sQ~jCude{pclGB~`*0^J<8OWa&G;V?*sg2*PgEl5 zZ7U9MT%PmxFNytg-h%Df=2%msMfR^5 z6MRv9Kf8O@;EUO>c*Tlkm=ceK@y!JO&ed11m%QIXK z?4RKhY)=Kn0v4@Rcq&ML(;oiGs=WS56v5@$2LB+?Kii+y3YCH!gHd)b7iBgMW5oISAq|w;F4=vh(M`ei>D@68GoyH_II# z?{a^~M{rqW(I!5nH5L>ZcNPTO?Sq&r+F*SlVSIzt-^5~P`X{ck^-nCjq=<`{`BU9G=|CP`Ow#I7jDgA!&IZvnR6buRfk@*RMXx;-}jJf$eLK1;&@y!(^*3*bUj>Jd(pA zN&|(+7H5$?Vw6SBqq2u~i49ujEV49=+9O?!zwhu>Q}<2UN+Yt@ujz3BayJXgru59v zde9xVA!l9-Fo_ebuWeGIEq4lZ z;XIF&XhoC`V?<80S>!~+C@0!HDtqWe!v>vbv&h+mQG2BC#7#>0YTt?b+cz@SZr@mD z|6K3^dDfgql37G)s1RA^EV4|Dvdno@_Ruo1LCc&)Hil7qq**hTEYUyx;v}{gH_SKZ zymrsc_U4i76;T=+M7B4JY!9PsZyuFBv^{Ll_GXb4VbmTe6_*>Ko>%)Dp~|;*Bh)hc zM}!Z^GUt(G7Eu~1M3y;=EEA(Fa~_pFv`lQ!GG~#EVbmTe2Y<^81hre#scpTkkhX?W z+bps+jPjrWN^Nr}wLw&Bn?+?0QyVmx+U8KYfv7!F4lYvmKPkv*zeI_h_RoIHY5QHl zPUn%F7Eu~8M0Prh>=dKybRLyGv{P)*PG^zDVbmVU>GETbo%TQW$XHwbBTtgsFNO`} zNpcqXB#BWPs(z)BLpSmy2~h)-CrOCPljJNadzdFlXfRKbbI8VUH1d@N@tOkVw9E6 zqq2uqiVa%nEV4I@+9Ty>8~J>^zyI-DTH24_DRaHGdqB?5_;q~qWo7xv+@29xCL#|N zpp-d>QYJ*D%vn_SFl9o6DRT~`F^JkD$y`R>j|#xU3KG{IRqjsm~teqs=G9K0zPw#ECHI&yqvq;*C1~n*% ztZf!q8%9~%JSux=ZP=i-%_6(Os6A2+HYu^Kb`krRXUkJc{Zc2E84s9lWVYwJEOQ=7 zW)Y>KLS&h<$TBg?GUrj*L(9YlEpryx7)I@pWG>6G`0Hi=u$?B<_OM+}sM~AQhH^rk zMV?SGN<$UHyg6{%wsJz9Lph;BR8FX~sO(`*sL)_esB_51a5VNvGTT7KF1O6=AC8pF zwx&r=v+LR#2b9z7Eb=sqQ5vdv6nZ1Gbt9+QIh4~ZL|G`h>)>hZBws|CNMU(~wk+sbtYr`nJnMY+0tqmKrwpnC17_~>zwmy8U zpWoXWCbAcQd`N=>kh@t>Hl-iaVH`lM||IC zGi3ibxbmqz4xXpG?bpnP@^m+ge7eIZ9mp{jhePAgjXd4WA*UKP=v13WWe=sW=zN~; zpz{MHUqyp^B$>Bl-C>jlrGG<%j-I9Jqm0TkAvBn$J3x85 zn?+?0tqnVGZL`R3FlvvKgE>%bLhZ|AG>WY}#xld(CI_=}CdPgqNlOu>p+aP1v&b?r z$};Cs*+a|31}$?I*%(Ick#ewK62;p3*9ysKt7(cBW0dESv=vbr6hziGi>wW!tZg2Z zJ+wA#(As8^-C)!nNn5`tN^0Al@!A?8scjZn8%B9h0HwA$l-eLFwauckhp7!3Ol@;0 z-9XeHwaXWk*u;IeHg!Ie1$|K%s6=ZkqU3pS+hjwQ*)Q60h#DfF(Xc@c6*ibBJBS*f zJP$%to(E@9*~2^!LW6l8oI^H-qp?SlxvXI0O62EKW|Vj8%U)YJL^Av9p#zYwSx`2m zuT|@=9=0J*b3pnqLh@!o(JN`grX+0+$O=XrgTpc@&byDLswlx#2DK7g=Uo)yZZ26G za&pZhcsHFJ_mPwrGfD;(8~} z!FeQ^MU;jLk!8*z%fu+loJVC3EfX8G%voe(7_~>5PTRsT{srk>x0as+y9EhiFycZd z_Iq8B;I^-PazTQ)E8af1o$C*rw}n^YQJQ=2%>@Z^6_tOUALI%J|7>Pim7nStB=~Rc zz4dFJeCyZ5f&@SRg?GOH?5)4{NAErV*Z<_%&!4^f58wXpu08XwKKksPkG}X9pW7@* z@IO7XU64TKduP%4$Ci~%^mC{R_s%Xj<3g{VZCVtix~@qsl~EIH630};mMEqs@z=$6>KKnWZO8tI)G>P^f3gtI!Z&~COa7g=@^+me9=m(1 zk=qNo-R4vk%cr^Z_w-!MU_|V6ACpe^B%RI?o1>VDq=kJX18M0OA8{vi`|DvGBL_B5n1LulCdJHL^g&|17w-AQ677YvP?vSs6CR*wk$*( z7wuxDUmsWVYOaqPLv_3&%;iWjG0HL#S>`;Fu_CHOHil6HWSO&39(#C7u(9KHVp^U15c$-(-jmZIuvEvFC{LXbHAFIoJ@ii}h-_>Y<*5^T zIBZaNx^Y{1>cj@iD9h{{R1Wsb4A>18yAP4vV3czZk=@KAX)B^iXP&E;C@6$14}KiuNIrOpMCwo~_K*pp9YF5Xo2(RU#Y1r~$If*(i@a zMp-7JLDU{e<|5_tWdt8_4VJYJk+flywIQ;$c_cSQREg{cqXx*@W}`gH7-emU22pz?Z7*~S z3fN8T$>wy}YS)KI+Azx65Lw$ilA9u`MDh1k)BsrO#0JYK%j_GJ z%(j|Byn3(Qpm{!DZ`CgyU}IRoK14ExQI?6wGUt(G7EvYg*kjZHS>|k%#~!0B6VV`Q zk0i6L=MXg~P3CW>%ymHloiSPgyT!$(H?!^KNKP@zP7&GZJd(H~szertQ3GVBvr$%n zQSo8b`9ahk$?3A-fSuxk1C|-Xyph>z+lNRpG0HL#S>`;Fu_CHOHil6HWSO&39(#pC@wf)V|^i#lC~{4Fhr7xQI?4)_Nj^*A{i?hERl_2)BsuLY?Q|y zqbw8AAZm{!vq^~_Hkt3w*E9CZ5ZGA!caxb}@gAdCAZbBlnTTv`9?4h{RU#Y1r~$If z*(i@aMp-7JLDU{e=CU?~f2hJ`2x2=iR8cy#k=d3Z7$V8UD9c1-ne#}-il`FV7)A|{ zWzI%<>@mtR5e=gDNHUkTA)J~0TU9lewiO6$ETT6uFGrGzQI?6wGUt(u6;UO!F^n1@ z%bbmhzpr{|nMH$T)V@KcJw&BUK!d0~(k-8UaRO^QUi`k*!4HwNVU)EYvbK37H$_y5>;|I-$l7M3 zJjxhlZHNX@dn9fBG6ZrHojpvPM@s}C&{Nz1Bn*Lc%>q5%4cqW@-vHzSBT5i|JsF|V z#BE3pf->S5XliWBLPS$-YQ%*IoJd#>KSatgjB<`4a*oX-B~lSpA}10?4UhyD+ZseU zC2(8LF+_u?JyMSK3lZ4Ot!^O>o$Ol8`w&SRMp+voYnw-MQ$&@>ql{4lWNoui9%YQO zHbjG{J(9LBd4-m?=;eb{~2+Id_ zs|Ec1*Z;#ie;fbzX!)O??pF)=@9(|yYo2_kRttFdr{DfN*Pi*cUwYS#kNoi`f9(2m zU;f1BW3_<4c=r4kUi{0?U4PZzTmJ9A_=?y5&OdI2bk{B(`A*#pz25$p|GO>_5V!9m z7hiMt>~K!b1p;akw?K-R_=&M3nqT_uyyhDpj}rL0EoW{Ykv($?5^MDF_5a<sr=H1BACdbRML|boT^PV`RIe5Wu)Dr| zPi-zMkwlwB?2d@tF_F8TXm8IKcm3zNeY+!KcSP)t$?^7cJGMF-BX@mF?v32_pRZey zp{o%UAD_2baxL~xPwNjPYS||R<=FM^r;{Y50hk_H#SwX)eZ|)|lLzs?W|O8qGcF3;mUEcVou+%J?Sp$k*REzq>5gaJPAUZTs(W z@qaqqX1a?1Ym!d)G3k_u$8;qcg*P9ZuAghHwf)S!JxQl~l1_;nZ$Gz_PUm9mbdAZq zvD5YQbt}%jtB}6%Ul+1bNNyc5@Q2ge!| zkRb#TRDkWK4^2tWY{YgOhrKBKq|5hpue(ie!J(+%Hws2Y{YsvGqE(x%sJ{f!1CFq* z_1BU^Q~n0C0_|sPqio2w*1tbAWLp4i3xTAZ2*}pj)L}1VYyJ4pSxtdg0{ZsS7xa5R zD=N-)`a-19ERZKVjPkvJ9;yGac)*}J&v$){?p3q}2Rz{+@{Cs?O?f9pn)Uhy4|}C) zuLp;&`;2u(BxQXuzm-K3hgnbUwmSa-@)%>3l@&;%)g$d(Y-`Y*mGv=){#h9!D=W}( zCk_qzG&LV3Wqs6PucWL8-IjE!x+2o;(?y7XS^9XsOc%=gE$#TFSgn$LfGhx`?6W`` z${uOYV_Spf?6Z&2ptSzkCnEbS&~YaY4cg~?lzjG4hrN=|9&}rh&nqJNY&#tH;M(nQ zEbUKQ>-?;(wJP!f%8`et6p6@VxFYRxY-`Y5idahawhHnA^2lS9MHWb7*dy(7Y-`Y*MfNcojn+Sl zL}ZZ#I_|`wL5rM^lF0v`xVMYd?yBy4yFcD{Z$GcGxA6yWEmig5TqKPYSF#eR%0(}7 z?VBJ~t#Vl@ghW2R=*^LGQ)82~apN@hDHz9mHbf9Unt)A0%aH>;h)@>-6se6-h-h3~ zsuK_t2}R|Cj{o?tvF2Fo`H#7uxu5M#E{yX2_LytV`CDVI^{i*j@#Ls^R1ld#Iub($DwZdRzJ_ZCx_fcaL`v1;{A+ED)wLBaGaR)oG4Ca||YB+TD)%l88PFwC=>* zAbpNU!Do(|N7d;ceCC+ci8aDa&bL{y$oJXPOWq@n;PVjYSVqNK+P6#V zSe@o#EfvkhS}IVir4m6|?!?@`SW8EvxR$o4c~o3W8%RguTDlWL3Qjc4Ti|kD2i-R^Qb7YfpjE_ z+zCOX57@-J$QL79Up(}r{pz7_eDt0B^jYjW$1@y>C_p0mED)wLBaGa-(P@r8a||ZM zyH1}H(Px3yotPV>&+#bu%u(~G;4_1CB>3DBVV%tz`l+ngaSmk?agHUTvI1eYGQ!}j zu0W?bD$6k#x7Q+|GKr|HK76qMztc~nrAK{^tY?TC?l>eM1rTuKM(&M9G51>3JSyg11L;W2y`2zp&xP2dJI}qxVkc_e@K2HXHym&@-&@{6 zoOu~Vkp;pWW`rTzu{zCBWR98pN0AayWP#S5m>Z>rD0`yvuYi+$yGH2Z_a`s#=?FHtO_B8txeaukMa4R)G~MN~8wi>N@ch)M*J zxf5x?7eib`+p*?RaS?4G9f^zRP6#3=A*U`k4&uzqsJK*$2IEO9&D&^*W+Op|di1dBr9mJWJQ50Dq z%wa|tvK_0_oc;AG1|q$G6q%cw5k(eg-H8-+F$9tMSo5gLuG<>{tC-Y@HNxkOyoH}$ z)&;J8w|EC}j%5^;6$n$35e8?+>NH1XIR=CC{!y7kR92vMC*}s3d*e}1mZRoTL0Jar zNbs^FLheo5_xUY{+`b>SmbvFU#XBfwUPOgRi8zN1VaRr@PIDo$#b7qPe<4z!5Gm2R z6LW(hax{t}Thu%%ifkYqi6VDG$h|LI^GBn;>6>2iCLy$Y9^xF!s8}wSA7i~iUiWeo z24|?#9F^smxqq=-iX+8xDG`*(2B~Zuak*^Ann%UuvVpnlv0UzipzIYY^ZoWzW;?DH zSGe;K=U7HjS%ENH8DVgCtWI-OmSZq(?;n*(L}dk9cVcdk%EqIhEJw|wg0c+Kk&tCO zBEMDP?_TmV#3S(w=6Q2IMHaiq@i|c<&b&kvSs=_|Mi{ac0_ZeHkvRsl;l%?eQX+~h z(7F?IgA_R)1(7*w9u-7pkd6eAJ0ggjswMEl!?PjMw(Fxw-yTkpHYbaWJP&c^WfVmg z2y>VbhHS^`G)Iv+X70NEO z>^0|;@8$OxU*Rsk5>cdVkXOTuFl1|xA~V3arXyiCB(iy^5Jk!;io8?=k-5QnRGt38 zR*snm%nM~k5b1MaQ|lk+zY-alH}_LyvHKjhB;w3VM3DtTMrMQ|e=kI4fN>d!4NMYI zq(l^1pmitaW6|+=6h!8zc~lUYK{^sd?uc+(lQ;H5S-$te;e2C%37|_xKvn^;H>KTm zVMB=>qihHZXa4)SPmm-56cu23=(#EA8I4#TV;)7>r?LjY5yzQTRbx&`;?PT*y5B%ch7dAh6dj6*V59Ce# z|JS^!---bIv$`BW+th#8y-(fYTN?Rm@rV9v)koIVyUwop_D6hEjjeQTgTHM#w{zFF zlF!%hw!Qq&W7==egIu%L?zANcn<-J35UsV}qi1Wj#4lPy=G((Ak7*m8(b|cTTc_Vh zKbcxP@n6!$^s%+Pvk6CAZ^4bfS{{==c+AbzvikLB^x{D~*2&qi_wT{!Z`Yeb<0x(4 z4I;TIlSg@Iij3bU#+mNh6cK6}HBSkaatD@8zLfWN%`v!LeX-%rceF9|z46v%-qyvw z<_$(Tq;H$D=N^8@4@F$wTf{g^<+-cK4BNDV@E5S-^6q%$A;MqaTf4qJg5kDFViEos z{)NAHAAiw?g9Ch#f8j55^Dpv`pWxVzEZw~8`CV_om$?=6(BaF`vHYWMY&5@%jKk&^ zj`mu5I+b*C}HP+L?wnr?>Yw1@i4Z#^o0R z5$7+9#an;9m&F*KUt7Yd<}Yw@0^T35x#ahi7?odK#zCjoV|d3YUyos2eo+H))9W!j zwZ9d|%kOB!@!C0txapMu_EWwRz_?ta0ddne(418H4RrLg)k8;Id;{$#hWx^lak=z1 z;{5xycx|Zn{TjpV`n*V`Z}3MA{L29TMgF!O2XGXx2FmXVqUE+6As6yvY5qm7mWjXk z7@x%WL&Nxs)&`F4$VTjwA}eFYv70=!oS*UVFY=36_>27TfPay{)5KroPo?~e{4E(y zWPVcgzR8b3apcxFjLGk~<6RSZ$IZBW?}s?Q**%%&;(KwYo^SgQmv4VC3cCk+)9c^p zg@tUfvo*vnnsJ9=wxmP~_&ZGbN%9oxw{lH`Y z=A|3?rsT0ld7lt>CCfVyyH5EX$l-jhqd>s1b=}Uv3y`|Cn&AHZ?laYcim+CiiZOLX zYZr^Ddr%QOZHgY$#Gdf2VsL*03+I&?qCabS;ScwG^fyXiNypR#GofQDLKiBA{xqq2 z(2J>iP!T(JF`k;xU+y*h@y`1-t2=MxU%ShuzkHXC{KHy8f1?C*q+@D=nb0v6p+6Nv z7n)Q(=*84Ms0b~o9@K>Xat!=&iNE3gPN&Zor~au9W#;ZYJ9To)L|R!QI<=43i=4z~ zB03!f|sR*5_82Ztq>On82?m zV{&@KZL@^YEpB_45=N2JOaJ(^8aX{im^Uh>CYZ@}!6x>gB9YVE$BFz@r0PL0rtU#S zB6GKUP?N~%76Z$e13M?&WqW1-^01LRCi3v)|6%{S#UB??sy#HAlNwJT9ntDI!rT>6 z9fUb5qDn-!GK!)KRCc2q)#*lw=uvi~gXlO%;r@Smyu%Nqy~jInnx75fp^+Cwg3(b> zHdO*V2W_WQI(PwKQAWT<0mdWBgBJjL%ajj0DKkIp#A@nw zd*U9cJqy?kVXBG-I|%bpM3sm#WfZj(sO&~Js?&`U(U|N;2T^8@f=rhZ-+T6oDbcA- z&f*H`52xvL9AUcrTs7^OZL~*hk9Ym)&3Qi{}Oi)=+DP*=U z3y_8WJwnJFMVw$6)j^yO8C9atBBDa3L}fR+QJrp-D2$12bWq4_QII(mRiaE;R4HV( zTVcqYYWU#NY2#ZY?fHmPC8Iis^C6>36f#9rXpyMwMmMU{jS_`1(TxrYnJo%3edaC7 zFP-_zHaw!vU#rn+{#p%Ay>Jl|bR1#Eil`34v=mV#qEi_~I|VAc(T(bKqeK)ZyU{^( znxmjCf7eNE`Q0LD^Y1#TZ5&}zil`34$cv~FQJajSfC81>=tgzAQ6jpL-RK}{%Tdsl zODR#CeXG1!CjF~sY8ywGlp?BwF!Ca*MARmuD4;-PH@Z=sZj^{_WH&m9+Hw?D(CPbs zuAsx;|5J2+1p@gwegDr-nQg(|4PhFK20I9IRYaADqGc476{ze+H>%T(649pYMh8)J zj)G{HVm_!LK z->+r^+VabJk!@{t!tIH_oEO%T(5=Bah zZgdc}<=Mc+&ZR^dZ286{W#(78F^m4jBxQ~xOjQxpL70ysszj72qo}1oWjDG}oo_#`L(~T0*m>gFJ#WLGE51G>o-+T|C_rf=w<`=%9!0Y$W={UlS6;U08X(^&gM5i)} zb_!H>qZ`%fMu{j+cB6ynG)KXyOR-${5B;*J5@qI{7?A1j=2PZ4!c-Me9fbKPqDn-W zGKyLXRCc2q)#*lwXiRpagD5jcLFOc-Q&=f7vn4iUdf}qT%u&SiUPg5g<|DtelDbi% z$V_pp$T5k^ZgitM-6&C{pXf#hMP{}r$aE?8h`=-R?bw}&PbhhB3uT(O#cfMe_^}Bd zN0_Q2s)H~eMO29>Q$|rsfy!=lqdMIv5sk@ibP#3cC}_(Yj$%P=x^eRRh9e8JsmvR# zc0-tyBC3Nh@*=84v7n01Q-EynqEVf0l!$I*H##U5)HW{Upi7A@ZgLk`EU3$ubi7g$ zW#(;Bka<3Wu_CI2Fds!!iDE$&osXqaqOu#^s7^OZL}PMX9TW>{>pW!o%-h>ozM#Gm z3u@l5N11uU9*lX>Bgz~{n5rVGgD@XOREa24Mo~+F%5HR{I^8G{jmd6w5M|~lEU0-G zWh|)eqbIa^#h+MEM-e6^Z_7*#b`VBhM3pF(M$tnGkPTims?&`U(T(gz2gQQg#)TZ5 z$}sUuj$DQb-~4!KAIQufQf2^^qo8c61o(odbvYfp01zP~Ag=)95#_-P059_3C4jCR z0a@plT4Ls{e|ggP88h?75z?!O>L8rFMO2BHLo$k)SD>;R-Kb7CO2qV%-RK}@UXH?o znF}jXo9*acd~leydGmYxQG`h;8tfp9yof3hwaF+7C{WprZd9imC88VIjSix=9EBX5 zr11HS+lCYL7u$kpujLH63LTPQ5mhRXn-*_Na@% zG6=7U>P>b->uub6b5#c6gP-}0r;e=F(hraEpW9de`JgU?@Z8rw|I*Q8|NZmN-1wtk z`@%~{zx|yTzjpYsKl$(zr_cYlC*FIM%OL#OBg--f*z$+sOm~;XnQT{!rF$;U6r~ZE z(wcDde=F#;UrgN^vRU__BFtqKQxhh+im8d6Fh$?20?Z{4m}6;#b&0HHIV}3iMOxqj zoAT*T6K?)*FDLC6Q@4hh&^@RKEvcBA(2|O&3H_-U`rDVlA1(AtBhX*2QUn*+luv(} zV8%5;HfvJH)C87vYZaj-6;l&hQZY55KNUlN`w~?KE?h112Al1}l_H#7Md(VAI;JKx zqFSp+7}<`gNf_CVsY$rnjtN)$5#$;y*h|vB<-L_WfW;eLK=91}d$0d@U3f3Nws)S9 zgV;nLUKODUMd($LI;JMHr&_B>*xQb&N!Z(tsY!UNMDi>L4nT$0|`M5>Xu#ibPZgg*_1!_Vz{i;OkQCqme(Ymjw)% ziuUn$@f5@N%2Q^6F!u$5%mQ_yIta#!21`U^GOB}UOh$DOWy&bZ+#ewob6r+yvkmYI zZN4j>+6shOE)cX8s1wyea8oo`BD#@L9Yi-Ws)MLaMp4`T2->VzF8(K%#ic#_@4}w% zf2X|yVX_MZdj;x5br2L44VH+CWK;)Hk&Nmf+LKYVw?BfmT&vhxv`H~T3vIE}y+)Yj zOO5I@2sg#CN<=p@s)Oi8Ms*Oi$tY^uA3<9#AWdyHl7+UZfHaI22(w(Ei$!%Bgqz}6 zC9=P-qB@9fK3G8G!&yLY`p19Y1AwBr+m7aFh=$fl)0n6Uy~EUMEW z#1+RX5yi=<4x%_2)j@PBqv&*hgy;Dt#l95qJJ4@ODfV1^fGYDocc}E@15{Zc%zS~M zvOt}v4uY?u!4lDzjOrlzl2IK*l`@Jd_eWS&bMXOc%hepA&G)%eTY)gk1%kE$b)q^5 zZi)s=L^m?3gXl&^br7}5C~DguVO4c0s4sc)v)AnZnZ8qd{Lt@9pW^?#jsNpB|L0wY zUSo2qFaR(1#p2@fIqh@rAb7dds7`~hSsbfGv?-%Hh&E+Z2T`<)qUik*M7tF0yo;8_ z2bi_FXaTa;_qj7`3xv5Z5Ke&tb)q^5#)<|@L}N0lgJ?`fbr5CBD9YR)LFOdo{Ne*} zns))<-36~o0HX!K7xnD{(_Rr}Q+P{29-{ydB_m1zNiw1Ya3>>RZa**=AK+^yviJbg zFYj|l`g!pIreA@OEd@gQ6{r){LCCS9!4fgYWK;(+$7ECoG5ur|({F!-^qZt~ez1lz zt*46%qn9M0%mQKV3j~=3>O^%Aj1>)*h{j}82if0O$Lb);l*gjX{Sj_lb4dbfvyJWx zZPV^|-p@#cS-#Y$PJ?h$9IHfhBcnQqZe&ykQJajSw*3*bJ;q-*nko#yjkUhe=7`x= zAk1=sIt_NBItXrxW0i<*WK;*yjg0CbYLij+k5%V~5SGrV_yF%*DUMYlx{*;GL^m?3gQ!hL(art{+P-}3pYC6xy{T#dbENIe zfCopBbP_Q~B_`y#BMF2#0|_y>7eP=G6_ zz02Q3A{+!yF#QJn^1tTfC=i*s zyb^jr0m`&3C5xn;5EDrvW~M})s19QK$p%Xl=_jH(DAG?vbx>rch>FbI7eVG!<$Ly8icXpSS6w{8P!2FCZjrtGG!EH?vEgIDl@=q zrYti+nfa$gu*~|7_P7CBzS=P%%>AWCbsB`R;#ei3F&WiCG$x}uh%#jqW$uq~`NE&r#j#36V=}6PXiP?R5M|0J%G@6z2lFpzQ(OK8 zZRDWuZD$S^2(w%uXe&@Bs)OLBXs|?dBcnQqZe&ykQJajSw*3)ua4I;!9F#w7PMLXc zJ7jvn0m>{8=Dt9XS)fi-2f<8x30$dnXX#wk&?IEkvO1OLyu)_|23_~i%Qw0Yy^Cr|&+2ma2DRV{(l|NEg< zTHvQo|HZ-0_uPNqd+vYW$B*Cq|33OJ55D`p-+JP<554>!-gEdn-}&4-4?q3cKY#N0 z1CRdyf4#}I1>Ui$Er30EM;q*v#6I=IvlC0zyUz~iM-f2|w#YqixXWVd2&;Trv}KPl zFOe{>RiByGqXa9Gju|I*))cMP#7?hOOjHrL=d52vfG%?7QrN@IH|=Etd)Fs>`QIag z_7tJL%ObR=TBE&D0!KP#oY0YKttPaWW1@zDEegJ`8J~ZUb=UpBC!Tw#6hM2{@*?*@ zXfG4kJDu$1e}_Fp(4Hc+cUgq?RBN<1O5jMxj1z08Xssr+mwNzvv~$Dlolf>n-QF#3 zkCy|iocRa7-~xp9GJ(C#WH0}FM9`ihw0BvA_Ec-MH%j10$BYv?Qmxg5_HqpD(avqM zrU3YvIl9NbRJ;DO$ERnL-Cmu6$kX;68@yqMd*o>&F#XV+r|sV(BJy;bM4ny_i9FqI zE%J1XFdJ3OIEifBZmlMfr!591;lOSQ-DFv1fcoS%c>2o-cjZ=qpc6xbIR+oUyl4Rx zULvAJ*&sEJBj_rkE`n%GHrPS5nWON;!spO78~L~0Psbi1QF(xNtdaS`2?f5%J=Wfa zVAN12CRX1TGdCB@@1>zwfklI{1dk$UlXJmOvZILFWK;*)->X%^Lqp|l(WY15JLY2y+RrO5^1VR^k7d57ptUK7ofY0 z;LcDdhVF6<+<9|!cZrDZWP@}!jv%Orx(K2m*(a&*#wI2J!3OXX3h-D-KYL zJt$nL^oj#inGsYP>cmiGj)6*Vjw&w^QKf85daOcsS?0Wj+ko+aJ`hZ1eh>#vpbV4BaFt%40K|cWH|;&=A{XkWS5ATWU@gf z**HQb6;T&K%p}=h2QkTV6xK_hLyPiD-w$HF%=HDR#dfVPE`7e0ohmbeN<*C(s?0G^ z>CI8)B_gVn4N~Peg0CX#B8a|ZgB?VbISML$4v*|%%!R58P?>#&xX|ny+2gX<5HuU= z#KdK>#Xz$+7nj9LLvdLY4aQ}06v3sO3x1>@MRX~nIw&rSt@F4neuO0TA$GJ-Tioft~XF_1PL2_NY%5mB0KkkZBxq!m#YK{O*9>>x_ZQIPf`%XxhD(C=lu zC;&WID$MByJq}XMOR11idX?;Up_{Q`|vxYGJhB`5kW-VrJ&idvj@ui_iGtpqA*(hQz$*7B<$R*KW z2Su8-D5Tj7M_w++xBgRrjepKC%(5d&%Lvj8bz&$j$IQ)9+9e`u%FY7WAf=5Xm?@$z zf@nrI*g=$*qmX~D#9n*$`<8DVO;wR{rGUt#_B}o3-YW$}{xt-ZhB`5ke=TNiF7ofv zkPX%oS~M8>H;R~KGU_5Il1wz%L6Ltg3M!*`gR6Y(cv6|G2T+R*eUX*EQJpF?f=WZ3 z7^=)MQ0dK4Uq#eK5Piu8JBTWC6jb^g+D8mmDQgE%i`?8E3vAmG z50zF{pc4}dY>R9lwG%Z@TF25tBc^PXz(J4zGQcr4!j)BqG9dGk>iHJsJgETshpsk3y2%0YLH&__L_oh?8x@w&xO3Mh+40U2CEyqCGbR>MvxI{#0vO!84M=(=FT?EmL zY_NkUEk{AxBiHl7P-L2DFfwfv zF`Z=8MNp)ZXt0AK(^?eP&dC;^kIISxkxR>ujJ`QN@~LMtTOf=X*k$)`;DqV?vpzx1@vL*nv$W7~1nGsZ4xqwa# zRpuC|^b!M9d5MTBWrI{Xj^L|^x(K2#*kMF(Pmy)+aHtY|P6*ii&uaxUVVdD&nYMPIVP4vGb~9Sb)& zxkdm5$W82&mJy^``G8IgrR5k%^O6IUc8Q46WP_A8jxha2)I|`@$Ob!z(sC4}1ZPAR(RUWW;`gDW%TP*&yXQv*2 z%d~Z~ZOM(u_U#@bH$JB2&q<@L2d%B^XUE>wgHv1UpO|Kgb{;Oq&uxd?U|McFWN7~F zESy@txA6QOZZVQ}?&D%T?Qr9Lm;4!5#`Sje#E9Cp11qc}kOf(Hkf zek_BN`$D`+kzb2pT&^;QIDZGjt|;Cy`3^=LubsB&QT}S5f3bcmb^0EeQzgGgj=1GW zrY|i|y+`s(%ji)%P0%CpT^Rq+F1?HMY5MYtLzbnT8P|LEA1${t4qbRVA9VSH-e{w( zBlwH_{E~l>AC^IgKXAN0eP=6+XvVRd5XAZOn%KfCpV!3kmeZC?1IAypKBML7GbgXg z@-rvn@}^>ByMM6ke7uRHEyv-XM{JLs>;Fo8!p(QsjO*Rb;V-gv%>0uln>W4wjhovy zShuFS9dQR>*=kFLrDwZoz&{+c`;5aW{;1@T) zuv3+xkF>jppE`Z_!OiFIzwi0`ANaB3PyWSk{^z6r`TZ~bt>1t7iI;9X`<>6ebnx9j z{L~XSe*Z7O^`0B~CgNRh=KX`di?GXu-9^|P!<+#qhk(0?9zjtu9fO^M3sV_Ww^ohR-m@=TtB73(S268g?#SzetwYr0p% zlUI;yUjBr4acC`XWrWl72n)@%Tx&7twQj8ZGq;9Pm51s1P zsu()WF|f2QG{h{+_d%FopkU9&v@TB)JyKXZJF&o?(7J>Z9)JM2Tx-J3f%cbqtmt*!Vnt^^ahshKXWvKVK>Xjq=C*%)tKDFn zo#E3mP8^&~KR31dbH0M(6D-dMdt9@)DxXI%e>w(pF-CZ%s2H9pittR)r0N^b6di*z zMU%R zh#6I&vWLv6A__@WpiYD3keOFSgJl#mD@VazE=fc;`Dq!nc@3d1!WfDu^kx)Mn~b71 ziKwkWWe=&Xh=R5Pbs8*(Ol?JjWfZmLC}_(?iRi{Ygv}Q&X!A#yU4$_dQRvMmqBa>t zZ4yyifyy3ITM-3q1?n_d4w>4D2FoaF%Tai0Gd)-4^OyWw8Sn)>zP)>Tr20ZUzI}l; z!=fmTjm@MkOrMW1>>>&S7)3NKqi9+pnl4b;Lz*t4V7fq^2FoGSbkSfLMbkM737WqG zFyNHpmIkd?J%c#)SqM$98 zCZe|d%?-5qH!xj>*RFh(f9rsMBCM zWTsltU>U_!%TY+R>5E@}Q76Cn4Vi6ag-rkAw~IJ38O1)1p^zz}LZ(0=Q=+nmAyYnE(6H)mx;OvBP*iN$5BL?GKw-KqRax7J*3Pc3Nj1SX|NnJWfl#VQIwgZAk(G9 z)(`oH7meA=T#IKm{)Sf5?^Lz@uNt;c1w(9*58oqBA%u)iYMF{ip&&Ik(mNTW=d4{Ffvm{ zF*7CVG*}KfGE+8KMnz_}D9D^%Tcu3-+G-eUwRRxWUt8@Wj!Z_ek7Fogil~q&P{@?1 z>|w~1QIsiBr@?Z_AyYP3Mup54g%6#%)DhiWUwxzDb#=N3V<@7~n^6=C>hg;f??x=B z0>y$VQQ5;-P-PSsREatbmP3vORW?{g#e&+Rkb_gPBR=@)6+5E1{J0OP=r4G85e8U9 zp{t{aPGuCGN<^mxDtkz$MHHMCsMBCMWI8PxETiZ&N5Sdz);+(ElyBYBSbpmsGQDb1 z7hz;Y6#6)dC{spJrbLukpt6UQSwul*fjSMAL#E84!7_?6a}?IqTm~r;tzAZ;&1>#- z5l=`N#ZzkxMGlIn$U%W32PG>*_qQIJ`nPJ`u;DYIy>jH1jO1({wD z$v(LGbx;;W3Yp6V)ptU65l1GY*vByxGDTF#6ewg$RQ51r$|%Z|sMBCM4D=9JpZ=Q?*$dVCIB>_Ygpy(AO6%i#Un<{%X3am$q zUO~&puY09b-_FaF$v>QlrSW`(Q>%!=NivFG+4JSn#%!%`ZRjrp+6zK*7xi{!U%12wNI&|2M5b!B3z5-oecm z?!WH^lsS6to4@gnqc?y2uikp{NB`|NU)uckUwrnh2Os^z_x$RupM3d)pGp-9UVHCf z&lL);{}bHbv(nL_@3B1ceF&@sg!dD$1FzLUvbqeGT(3JjnlI?sQH$yY_SVrb;fkSJ zvxzNQtBKhuVz~UMUQB@8InuB3|v z7n|pquw#UBRSe~h5z19Dl&c8kDnhwqggSH#JOdQr8K4NyfH6X6ItFKeCRML{ zt*v8l2Ec-yNqz>biyz{4-b)EUVBR;M0B%v+CFOhodolv@#(+(Xj(|DY6vRmYZ({)S zTtwglfS3}SWeqw_EhF&pOOz{!t7rQv2OpljTmkdT4lrllE+{UbA}X$+V+f{jH4;&A z5fvz|q5{QbbPQpli*DFX=Ngrr?>4xO!l$&U$;%$(aD6FW(vAPYeqabrCZNS&Va%lrBy19q} zo!SGgg;OtLK&PXKPGuCGjv_jhQFJO1ok~QfqlgNMC^#(;>`2sYa2*A0xrjk%YcH>0 zUG*Xcp=}I>HW3xt#!zSzQK3zs&?Zo58$&V6GK$(Hq5y%q4X&f0Ef+DMHv8`)+oFuL zohhS;+GG^9jUsB3QPd_8wMj&6qlmLyL_u4DAV8vSgX<`)pf1Ix*S`So6)~{G*^O|! zb;E}SFG+wz90RZ^Bj9ojz@v&-r8P#nNqH`2PPf~b!l!Xl_GZ#d_V*4U~al#t2CPxuv$|xEeMU*L{C{rTJ zl!!7%5$C>$g3JOzi$vW9*HMreQao#Wg$?L5f4vWUrAwtj-g0U5fuqKh9W^lR3xZC zk)Q%af{vlcCmF>Am58Y(P`AN#6l6|?4fvo{7B-+vdkJQ7>GVDH%*;_lnKFtpM-gSp zD9V(GG9{wSQN+0~q9C(C&>~T{!F3d5x)e)tUu|V!gUHPGwF{P6-$Ngl&M_33DWW1X z$53RZh>FYNdEJg3PI~0hd`>*dS!KJ@m*-FKiGp z$56-=Q6X~-g-j6@G6f2m0)@;m6mu`5C{rS85vbeXItntUA4%gfD}N-7GIJ3j$eec8 z;|6FHQKpQd%uz&{GKw-KqD+Y>a};szizvt}5VT0tZEzh0nJy(NJN3#P(5byrytohY zo%Kx6QADRQicUunoysUWm55FyqSH}C1w|B`76^7E>NdEJ!Xv(K9Q*bcuU>H zLvcA2QE@pOLvcA2QE@pGC@zNr#pQ4eah7dw#9~>RiVg5`C=mq6sBVMnD6F7V<=KaMllB^Vm1lX zZEzh0nbXYk;m*hK`-jNPT<(A}^UiuKsM9Z}^4@0osNg(AnX*C397U8VqbO4%%9MyQ zM-k_~h=R-lL5oD)2G>#0mdhPbTYljZ+I(ky+`cS7=$wbBO*TkvqlnsM6tzi2Z4y!2 zDB>&^QP5T(2#~1T;5rI9=reCGTKa-2e@!@YaCt8}?W|`Gj-kjw5fwQ&h9U<=ROFyQ zk%Iz74vwKn85zYKl!)0RP`AN#l;6~FE4EiJptk&@)5yUILE9*zHW@{2qlnsM6tzi2 zZ4y!2DB>&^QP5T(2#~1T;5rK0a$y5#%O6be9*$Q$z`{8OAWTL;*BF2*839oeK$8TJ zGzKuAMFif%X^At>72m@VpwrZHfb-9q^XBB^@$=-l*a0&v|CR+Z%=gvvDmRLlVKRyt zHj0>GGKv`{5i?98X4oiVLKIQRumT}hB>Wo`eYI;w%?Y&{iM_kf__>Itn)@Q>_W+;P)a2t=IvK`R01c%;oSC z!c}e*QKpQd%uz&{GKw-KqD+Y>a};szizvt}5VT0tZEzh0nNu+Y%6t<0jggs8o^1Z% z>;J*#cKb19n_u7ecB9okh++s+?E{-j?u?6a304MyFXC($LrAp`{*QBOAN+oLm>hHoEk|UA8M1+pM=s9~_-svnjR>_VY89D1C5Pl7o^QpIu+IcF!B` zvO)@VTi4F;W9RDlM_X;Ro~@4(yx|u@8~&>LU)SL zog#9<(48W5rwH9;0(YF7b;$$lmu}?^=q^|PLl3dB zp6(Q(J4NU&6U@Wu9<(48W5rwH9; z0(Ug9M`;6!wGH%(q~xZ0EVYVItRfVf2_|VXiOv6xNy1SmRuPI-gklw;SVbsS5sJ+u z`V6u8-=ohIs|dv^La~ZaY$iy(jW3=zyc*D%&C~17Homx?O^fT9^Lzcjs;t4ad+n3* z$>z3ye5>6noOzw3JI+owsBiIeQxiYu#Y?z*9;)qg;Nm0zVRkFRlRy!k1est$ADT{r z{O_1h9EB%=B0LEc;YpwfPXa}F5-7rxAd~1bPJ;aJ(Py3nitr>*geQR_JP9(1$_929 zx2kLa^03X?YbWyX!DasY`Or`) z0Zd8&krKe81kjkl+}YyqAu<2`+*vr301735!3^+B#OH}`7CjxCtF8J0?(d()PmSMd zMZZpd*2>TXe3m<4e2;xxa@&I$d{}c7*Ia?(qAO5bbsNHPt-(%naoue(b0^}$D^Of{ z1&T|rKymFAC@#JY&0Xi!w}I3@+^XmLK{JZ$uZW5Zut0GIZU`c$dIN8_akUgE zE|&tu^-`d?U^X;&omb2TQvV=QPEuSmMO0ig1&XU?LwHE?2|g>y6%Qf>Tk4`yUhyDu zMxaPRfg%MP!tkxZPIHlhEoSaSq@X~Nf&xVf3KS_QP^4f(bJv-I4W#}t1qF%}6evCkbFFgSoTC-}6&M z`xTLoVgWsL)-nB1SwoREkqCJ|FCBXr%0CUlx(iscxb z!d_^BDJBt9Od_V3L`*S>m|_{tT}O&#kot!dlZYuM5mQVerdUQuv0UFEl(mQ9=#6is zk2w}7lnE5d8p61(!A^6bti{Zo2xS6=GJ!&wK%q>aP}b1gbt-Eh^^eK~3S|O?GJ!%_ zL&!ZBV&6P^?hV&Apk({jb#dwQZS=Gv5hY7R$r)j$tieuml$>KQiQWlHmWYxiqGX9E zSt3f#Xzn^BXOQ{_$r4erM3gKMC1-@Z{K%nyMeaM|Ls`3@L2n$z919f61PWyhVcgbW zr@2tpV&+bSGJ!&wK%q>aP$p0)YiRB|l{Jw1M`Z$qGJ!&wK%uN5D0}hHzYqT^&H27} znzL5{7RfekiRV`t0@0j|qPdJPd~1;AGQjZtNHixA%}GRa649JQG$#?wWi)pk<}#SO z9^Yz+NuoI!MRO9-Tt+bW{v%H|-`!4S`Ddum8%LaDiKt8>D$5Atwgx-RQCW_eJ3(a< zQJF+kCJ~iML}eMxU5By^QvZ-_5>c5%R3;IXWdvm}P?_&xk1T6aP$p0)6DX85Ga zP}UGW&`qvR{R5q>5D?4d@;=BnqjTnMOZp=B9K~`eP%M`XVKywP(_Acs&4yNd4n-DNrnz0>yGEP%M`XA@{y~?4JN$VhZ|Z|F}0< zE{%>j)p`2^`s;}1B%-;DFno*ZG)HqeWwr zq(l@c5k*Qwkr~ZhhsX?4{~%H#ij;^VC8EfTpe$Dfh}>)UH0aH=0iB-<1d7}fC~~hM zjN2NF+-qPyQskaMk$VC~?g`K#_Y5&0S~iH86KQJ{JfSxhGKMoI0pM02H~d;NqLlIr9=xq(l^%5oW_0>@-J_IR+EuouEjGC{iMdl!ziFqR5Qqu0v!7 zsei~li6~Mcij;^VGeYj=egC1X-RYn=zPUf2nbUuVod0$jIfLs8<1q6cQ*hQlQAlhAWV3KSVBP-LV) zk&yyLMm98eof+9c>K`*wpvXvpA|nNgjBE%ZeJ<>WT0A2ki5-)Vq`CkUX$>zj((3|n z<|U#?i6}B7%!W1CX^tXu3?|AuL6H(sq(l@c5k*Qwkr~ZhhsX?4{~%H#ij>Iyv5KO| z90ie6p$0Ck&xXicDF7n#R}tvCR|>$qO8|)yKw$=WJ;a(S8-l+4_w%74PXefu0OBNo zHVGgtgSoTC-(v~Qe?NB?x+H)s37{&2xk>;v$tnTNH@S_S2_g~mO(N!7Mi`Nm9q2U2 ze9JL7pDhO0KAh?jG2bL&zDdMP*+1A$!r1U{-&gpW zb!)2;5Jdy#8UffrYKOo1&XdhAKk%l_8#h0Bdf&Mf0RA9X0Qk490>B?U^X)&m@k9H+ z+tq(^1%SW)+rD*iT>$`FACG)r;rkk%&-5<@#!u09QDSd z2kocK@7~frwlcHF(nG1xxI#aV)Vr8VIgat!9>A)lSJj}i^! z$JbaIe1l~P&~T2Jw*hifL*536RXK0?V{h{gZ!9>zzpR5ek{pE_;%S>;XIC1V%(Vb; zG~Wc(K|ETH!qKL!Q~Vqye@2!~%B?>g#G~aX9BulYQ{F}Pe&>`e=Y3unqHi+kARaVF z;h-L6o2i(P_)~kFmoPdm4p1O=>1L6RC_uHbY%LhLGep?5{C|VRo;Zd=1 zL;mPXbhS-6M&Yk!bx@3=Md4^3W%qU&botm&>V$>PTzF% zq9MQOW|R4gRVeW-;L>Y%+gEg;Ds|bsfZ`A*5dq8pO?E_?C(vlf1?-sv(NN)Z+6f=+1u6M{=shK+Gl@$ zkiP{Vgh2_sVa+{rIE*{qukGeJ}ryw{L#u`~UnMhu``5pFes0iO2rx zQ=9zJ|6e?Mu)}Bn(=*grw7HtIF+Tg-eQ`nT{`>+2LPFtoOpj2Zim6D*-Hr*l z+a%;FvYs8tg$29bdvb23c4`1$bA zbUti7!iK>7EyDZ=8~!AKzEObR187%@`516lfU=BU0c=`yQyqX`TM)g1H3=Y10yvWZ z$_lXTEaWE69(KuEGt1Sv&S7a`H)v6yKR zG1Vkuu1UmXE6}?0_=xFJEXqG(%0hpXd407t&%Yd@Oo=FS9HEagiZTlXnWKotWKxn`R`jG9JlILLWtG`Iaw)$hnqQ0D;mjXp9jv}66 zA}Uf*BBtUPihL7MUBrBoQ6-906j70i0!1oHw4Nw9bt(4Q)t$<3Rw>hRalQwI%=nJf zWJ*Mt;|P6}QIuIA$Q(s9CZoCt#)_yCQKpQdOo=G7K?h{j}87r|H&RU*oiQIshWWfo{X zQII*k)xz5$`Bn>M+J~n_X8Kz#lqnHqjwAF@Mp0&gAafMan2hQo7%QSmM42**G9{wS z0<9+sA4zt7X^2Ym>p#d(e`$zHC8E-Cgr3SMDlHI{jv{)KQC$RYMO2BXR7O#$L{wU! z^+X{*U5Z_BeX;$>p*^C5==`P@W!mdii~OA4xx(F$M3gy>&_@|XnFWH(QAA@hs*7N( zh$<0f$|%Z|h%yVbo+!u+DK4}3A{?FOmw=I={z@AYR3bVZN9d}IqSFGw=_sN&8P!D) zS45SFPGuCGN<^mxT2B<5x)ghB%bm*i3@J0eF$kIdo*`vQM496VeUwp@Ss=(9MKmU( zx(LRKs1i}8jG|15D6>H8iGoa*Vy_dq%op(6kyvf>%bk>&-$aH?e@&AzC8Er6gg(kB z$}A9Mjv^Y9QC$RMMO29>Q$|syM3h;e^+ZAD^zJM_r^t6_DbrqfU0gc-E!emZ5{NQo zR2QL-GKw+_1ev3V#$;3%!B`PhBFdCelqnHq7HBdWlAqkLC>A3S8X zH@mUSdgZ;4DNx89MeL)93Yij7<`@cNBC3mMOh%O`WQwSeDNx9iXgyK5I_B5ysm_I8SX7QQJ5|Z)6m;6$si!5#7kBE`pmPszlT#qo_?HYAeusqL70w#oqk(9PI68 zpwqlN5UJ?<66jPSIvq#os*Ix30>SAhqBt4VMG#j+m55Gd6rD;$rv+M16r8#gdmq7_ z%FQB_nYVpFrf(LZOo=FS9HEagiZTlXnWKotWKtZ4yyifz}fRZExqc9p8f# zm&4_kT8`p!C{SDuM-h6n-s$GYip!yB&|aLVqe?_K@>pF2H$_y5;&LcDAD2Uc;&Ld_ zdZMs&PJ709Ih1?GV(DDo+e{Vt_?%UsSUN`$`Y89O#nLGnq|73UtEz~iF&WiGFjhpB zD3(sqjaWJbiltMc^+X{D^Oihn%bV+P#q%wB%t48$Z5*LDGK$&?1UI9IZe&y!!A%iW zB5IRS)Fu(N6=*$C(3W>RMz$?K%}>?#m~8?@wv8g5L?SA(O(Le$7>bkI;=Z3Tk1QA9T~s*B*Jh$<1a z$tY@*h}sIYo+w=LTuSVcmiu)fGw;5|JNdp}7b+!y$Wefo+}p}N9|H;tu>HfA-YZ=G zWCRS20=(WYdSze6YXE670?H(SumUW*3t2uud-m<0>}1))xXvx_czjcHT;~Le>)a^9 z36%?^Aj|z&ah($l^7f>N;w4Z-F}-9|7a_fhs1n6>PIM!#a{|S6PNMZhVZqFM%Huk> z4E8zwFeT;~Le>zqXEi9!y# z6kA|D2fr5u0`f+FE|~4YiN(yT5puy4fD7gr!0I3(xL`_vb#Vm1kc{X6tc&6pTrdUT zf++wOOaZuHO0ewiTn&M$R5b)UseR|$rqw#YKiFH1QR~3oxv_t+0Y`a+Q?He1Z+qN) zPn1VsHH6o+Y6wvt!KxudCB(bVt%mTiRt@2&PyaZoA^iC9b6@}bOGl6X$>*QB@keiY z;-#al639>^I+?gxFoF65~cpXy_E(I#a)E2FAjZiUs|5cG*Of?7Ft=;p6 zyR59_cF*9APfik|?0(Q96g5u5U9^jdmU7=;OFR1+B01fx-e zMn?&}>6nVpsEXmdDMCAn(19Yom}(BTTZ2(l8OFJmj{PA7Z$D9AATxwqYmBT%Dl z!Cx$^*p^R?nn0r_7=j|yI7%Q($5ezGRSY#MLM4iD;uYz|RCBQ18Z>Sujrl0hn9Cq= z32kK%z|VX+z1RP%>d9Wa_sA+_>{wPdvUzzLV9$MIvm(r9MR+D}lgQ>V!kkqx6^U%# zj)`pECXt!jB=T>Y^kS+x*lrD{!9#O4x1&JwR*eIw$~(E|`U5ak0Ni!94Ta}NK$DE< zfTGL*Z>z<@p-3JBh9tmbOMn@bpc7Hd)w(JCgoG7AUiWFPbj%kpdzFqOwb~Tm5rl8B zkKB@o$t4lbEP*1uMiJ9UMwKX1OhiSF2^0wW}gw9JumE#D$il`D%rHtZKNkl0UapooJMwPR%-5^w2wTbQiL8X_W zu(zrG0f?;8O_lknCbH7E+*4(Nc<0lex9$=}m9oJug0CW~L{urGI8_o+ibR}wiMmlx zDWkRh*F zoj z2$eST+x^2jD=HntHFEjAgKw&*J8Nm7bwa#GN`&6ZC>!{N5CjzsmME@~qKBLt*&r=Q z#Hp638&%H6c7t$d)4Sb2q~TQOfN3b}98jfwm$2~VTk5H@K#)=(bY3E=97pg~M3smt zWfZ4MB1(~nGcQp$s+^7O2BFese!G82!~829v34$Zc6mL6SUV-++9?sbX&0!)S>O#) z+BkxlBC15Oc8YFrQe=aieu+585_O}>+1PFn(rlu(`v+;eRx_Z`bx|7MI8UJkg3tnC z2oh1~ID)kzszekjqc~v_QH?~Le~G$L2%$Ff+x>&kT(yAG?7xeo@eT5nRv<_# z5V|Q5rHvz)DWXb5X)=nFA`z!wBF?cy-KcUlwi|>roB8ej;VKwP{6l}QW`T9l<}3ex z2|h#k_IMgC5R4Y6Y>-A}R2M;85mh1@l~J5AiReZm3XrH9RnEqCgD`3{zui9=ohlY^ zmyoPjK$YvC)$q;nafxiNVZic)c!`t z2-J-#XJflTNVA#W?jQ1Rs!PDn6|yb?Rj#W9O}n-9O}?&$}VNqmk7In1A*{<>Kbls}F=qiKtQ{ zc3z-RIg02@MwKX3il~?>fkKKvG4ld-qsrOXZV)PM=C}KY{L8fmV$obajG4B%&|GS?iS%5@bjooOy}5QRQrGHwcwB^V|J{w2!e+#9jpixM(i#47|ZuY6Xf#a}>eM zCGPjjjGZ5-5^Lpl(z-8`}*+rOo_y|DZC; zLb%FDVrS+fsV+fW6_*cZrk|`vH`ZloCd8}aSX5jUMN}8Tm+S^V97+^dMR6=nm5ibk zi8%8Tb)(8T+-?vmZF;x+hpXbVls1#YC?FOOJW`4VWQ0YqS*{m<6Ux}qOS1h2)b)k`I zb3D3HAV?`t8AV?*s*9kqh$<0P$|z2iM3f>CXI`RiR5=^l4ML^O{C5Aal;+9=kY;~1 zKhH*>S0+IA6#%<`Mj~KDMsxtSH;H55iYEaiNnrn|0j63;U^XS_L=Jr#w`d`d-39KT4ZzxQ43GfdGR;b{ab)kY(m*8)m zTbJOuT$kX>2RHxx{`>y?{s(^i_^Ho*<|_x^ecx~W`pJj>>x-W{eDq77dgfc*Syvkf{MAR6t1)*DGfZp={cJv!H2I%c72<+(NRD|AC483In zKWZO;_3tr$Yh%@~tB8O%&OW`JPBlGFH`jmW`1BV0|86{baC(aRG!GGWcPMVWOpMltoHR+{~qIy>^{Gx%Zz|+%)TAnZ9g>G-l{+VaqALVwnv`c zUVVVOXCiX& z>SOSU_`THS``Ecou74ZM5p`V^f|KG{C891FMO_&|*Sx2HCjed3B;khWM-c;EAEGWV zA3$AZW|3WvsOzc_oD|0@5p~HZ>dFYZ}p?!(`L@be3 zX#g&BO@TTtlvG?2=>P z(q-63VRtF32~gs?7>RFPr^Ks5FjX9@M3g9_C@~|xC+iS|fwv8s9<0ZP0o1XIPaN<@h=iV`z|M7c}91Q6mgL5VvxcF8f2ICX(P zeE79kRYhHaxI(t`6TO)>vGX(k)gTDVckZbhC5kJg=wVzT8-lX5YkvnYcY!W*@5;t5 zIR+QOZ}W!63j|P?{dbXij+lB^h2W$(R*9%fMp0Krm|?jyzXX{2X_ELru1(Ta#US-U zhF=7K5UH1I3D_`w;c6FlblrBe^HV$4w5vfdRUE5Cv3`mk#`@V1=H14Hue6p&D#1Jx zv3~B**d@n6qRX(`D3|#7p*?*;Chl5WfP&?g`M6bGUOT<)z^i~T?L{|AM8PtOf-{0g zxpTh+kmbXr;GG(~!ir;+h%RLmU1kKAX%Bxp zc}oD7J`;4gQ)8DLgSGYjM;o;2H38xR+3wM1L|t<4{HuWAq&QZI;sPl;9~a1mU`6iJ zFTp$$ae>^Su}h9Y>bWO-;mlL-1^h&WKd+%g->V)u*ZPNUIEtLR8pOns$0||eoQR5? zYY48=zWp7*JQI;~S096KF1*v7iUM&7T;8mBMFA?5d+1*U1W(1WN)(qs(fPOpHUx!o zzkUgz&}SkpfjcyI$uY=7?}FV#c^*E7mu`@UxxxS?=6BZ6jcGeOKjF7`>0V6)Q$;sQ zM2Rwr5;KCUdH?=SU>+_d{wQLg>)qVc?4<&zOI8tJVqF!2lj2wxlcyU<*EX)aJDB9=(?8Bo9!&o>Q z!o15p`z4rXA{Nda8oT5eNOT#txz8njIV6fI1CfyJRvNwWE$xwzSA&>!@>nH`gcMPc zkPSiEyocZL{K2;b^Gwj?k0J&xU54!mbeFQe042(8^OSg12&Rf-m535$6eVT^S914$ z2_VrT{AHt6@2)Ba7r|UvfV$QNE2eGiyk}`=C;Ir0lIoJA!ZuoZf$hoUQOe}e<5=G95sK~j7;41CU-vP`s5jl7D zF_7po?1!6OqO2Vdnb_75blnRCL?&JhV!Fv=l_)Y%L`5bx1c`EIehKE8h)mp}u}hAD zM3-TgUYGc6>{`yX1SrvFWBw?FZurJ^O1vrrQ^m1LM2Rwr5;KCUv{SzgxCHY|P~uLF zU2+VbVYAb&P}UHj!gbwH->^=FSB2oII97?MP)1QOlL?~XjO9^Q4v-RJm1 zdRZ^vp0gv%=zGqN=d%*~^^PsZ{_O(fS`l;C*=b4e-I4j7kxXDie$feY3hEtG(tRCq`Z~k(0^aiSJFuxyO;_Vx#a! z0)L|fBULdRsV02#xSVVBfFsQ@7!Qc0kdL(O&c^S-+Ya61&E{9y?(EpIz1)r+LvG(J zW60Q!O}TkH@-Bo!-fk_1tO$p^O=8H6V94Am&Mm8(NnvbY#@J%BGXC~K8^`tO^nP&q zTmE+XMK&;Vu56`UBm%b3a;`=R=1RqIt~9}1DZ;tR1mj2F?B(0l_Fj;OjofKp>0_Js z!_&+>yuszcH`ijQ>qp2FI35PqbOeSek8zh@!IvMp{ooj6j=)9eX}*n+_q&Ft_QDJ9 zP<+2@h#EyyC8ENWM3f~^*lGwjJ>Elm70hpX&kM&=tLIkIttk;y2ItUwr< zL>yU07@7AlDq70lXXeP(h2nhU9!EBgFftj%krfCdlZYeB2qT-e7V<{5x3!Q1%r#t~ z&UdqMfa3@Qlu;aDfiOUcIKYfBK<}YlVEv?&`|UU~+s!pEix`=2@#4tF5k@AXII;p^ zWD;>?8DZu4BKN%bHYSe2?qU~Xm=G@g;|ODrQ5-{oFb0V@hKyjxduZ2ax0Cl^0lv%{ z4aW{&j$I-zbJ)K9io?#Ej-YNryyIC~Js)A%vOx~JKp3_}9Ck(+nRml}r_4tte*iT` z);>XDWWH~PCuRPyv>W2cM1wIhi8wNWVq^_rWZpwtWPD_DXAeh~x8I`sk7H*KM>dWy zG8x5@6$m4fh$G7gBl8~G>f$3C-tNLt=dF$ywePUtsK*gTEu%Q<0%6n=anu=M)YCSU z&K?ksEbqj{$b8!YM>dWyG8x5@6$m4fh$G7gBl8~Gh2DMkHb!uO`JI0Z(6<9{fa3@Q zlu;aDfiOUcIKYf>eaO27;`D3JtT6_EK|M~tQN+_vM8)YR5l=sX;`D0>W3Zp$!#%yf z5sxwCg){-&)3=3w7l3a5$N@|_=>;3cg&g3TevXu6T&5G9AV~V z6lcCbn0bjf^BEyMrkAF<+{u@wIkNmdFh=GtP4k=@M;Mum;>Zewkx9gnWrUGUuXQu= z<*$@+WcGx1aX00!v~pzQ2qTkG99e-dGKn~{j4(3qq1_nxk|tle<;d2**WmAYa%AHO zBa=}aS%EMzi8!*1kWu2DQx0YQD+m6XV=QUyBRI~?^AUz38{|+5grP{pp=5-ie1k8t z``d(}V|m*?AuJC0twoICd_*0h!O$TQbqEwX8p0Uzdx()N%gOdv-y&H?5tBtkMY2f5 zWDzKmr6DBC^l}~-IQeoON49=7+}{V|$i@*yCZjmA0%2qlaby``WYZgK{7f(3Sc~(n zU2Ji3`FmQi42&Y4cOojzJBfJS2^8mDLpbm9Yg}QcS;ZLq&7rU}ifBhfg&m1#N1(9N z5bR9vHu3T!-)-W^?9G|Q<;P#A;mF1jMkb>;vI1db5^-c1`Kpk97KCHS?`*=3zfHn1 zj3bOeMsW-U!Wbmt7&3w#@1Z4>UqbWyF1V@mms&8gd`po4ZbRD%y9>Z=sEELb3V_>C z5rNTU03RQx4}W`sKJwcO@G&7iAZo4dhTuasNFN1)4~gg_Blz$h+Ly8JL;h+)oN3GM z`xo$Wri~(=X(B4lG>Leo2^42qLl~L&&`v)enS4cnXIi@n#R=uF2w+_K`Ohu@XPRh= zXPN}XYY05kS_ICt#W(5rPS5U4<6Ch1%H00JK3=ph&hZZi?Ys8FGlrbr{GIsX{8sx5 z`>rs)TDO-B{2%<=_+NhDO`A8`H|ckr`%U`C+c)VyefnPyZhq(f`@VxZ`~U9G{`8sS z|L^;sdgtxW|JtAa+Z)e*^K;K0efM|&;MY%{`^*3M^~3yC`d4?Vvu_Kboz?a|^t@ol z_vZG@_5!4C4O{y3aVlcdU&Pq6tYLzmkI+4^ukH$BpN_AHtmh416i#n1_!ouM8v^ua z-?%UQ@mqNGeUzXdItFtzM(9t)(BBxLKNUlNiqPLxkoCO5A1vTaaD35=_o(m{`E~zq zd+&k%@;9sT-UIV*l%O9v2L8qf{izuG8zc0mV(3p1`nw9Uo;Ud8(!R$V9wB%6)&kz5 z2)Sc~om4TDJ3>P4c1*|}At84=Cgg6Dko)SB^}InY-({iP_AUzu-i^C$dUNB(l(xA* z|1F--yB6rbcCVF~KH0HOKTjB&%*ASsS4nunj1ml7$KZq+BRpYL3{RLbVt-xqfG3P< zjVH`ikoCObwBQRpd~K(!*AIbtxx($Ozdr+iqo8aG@0+#@emX`0piV}>+!%m283An) zz}l-1mUD$0d+~Nsq*wd)1Rodu-Ji&>QN%MvM)3q6Ly=@6Dza=0MV^VMNHl>W)2;@s z=MeUCReoyAn*gEBUu>ebafCw_QP4Jus7*#u+bE(o8AWXpQQK9a^&CQ5uFg+wdE*eY z`CD7mHjZ$}A`03@5w*!EY8yq=CZniLB5J!Tw4Ose&~YjD49*|u^j@W-)BMsmoK7#Q z@$+W8ci9c0KShIZI*RC2M$zdgqEi_~rxMZWRiX79!s#T1dsyY$gCTRd%142CpZih7 zj>ss=977>fM1{;T6f#9r$P_4KUJY8$A=cIW!X&lj-2q56e;twQ>Nvt7izsLtMbsvv zsBIKcn~b71iKy+W(0UG$gVQ^r{Gv9$BMPVaZP2(@F3&K3*D|h^qX_-6_cZ5Ek~m4G z26?RKAX zU?U{qPD9->p4UYPEs!TLNi6@_xX{Z{<<(lk0W%lh=P|+M;U5Z`7{ey|D<4>7+*Ary=8`6|Hj?j@J3Nl9#Wy&bZ97U8VqbO4%%DgJH zo_TF)Vzx|G-s^!_LmpWol7OsgfoxUBm7`;MF(D*97qKG!h!zV(AL#}PVGM8Vi7qD&b@W21;NWfWyfWdB%o z{#8KhIYbWT%Ko8k`C-=gwS=}!v`Y8ykLO+_@+*}P>&mnT~{0je+ znYZ#F2dAR){GMak{qqqzQZxveqlhwP6lIPg%9K%*DG_B}6;mAc4?2RJYlToxcifB(p(Vj%KcU5RThe*Y#wm+Zb$lCsqiftPgWcqG0%9I<; zx`?NdY>=rqh9VV3RHWh+bwn0or`$8r_T|9I}Xl zwoycFGK$(p5w*!E`^Tz>)Fz`|1+<<+p4UY=IZ{jbS@vg_+H6aA4d@mnJeMrJeV5f(kUA(Q7oMz zDwfVM6icUwqBhz2SUN@2tAN&X2yJGP&Qbi$UzYmIXH$Q2Srrmpg@s> zSA*7b2yIr;ACIAZhcmS0y~Hzsv{6ttg(t!7Qjv~P0H~4?5H$uMNk%}C1Q7JAO`!Fjp7&9|t%8*ZueX zFH{$J?wh~yj-yXM|MjrxGj@#FtlF}nVr+U#f}i+MF=nSESe|rDMeM3u#MoYsVZ!A{^?<9- zz65(5QE}jP=lmuC{n@_ug+K0f$4rb7`cpCVrwMq!T`Wqwkr()=DUjlzz z|M{Z$W__rzx9jx+pF`dvSNczWd3C2sLw)xZXKy3V_A z<6~i-NUjnNSx##rLACQ$#E!?Cmr`=xS^#{98M*)9PQy>y-`Dnvy3PgsDp~x{26-g!$ zvrHiPLo!LkY#Kw6X(B38Z45=SiKxi7eG!~aKk~xYTjh_uP^R2KPnn~LGG!EH76>vW zqRaw8<|v{}8AX|+h%#jqW$uq4)5|~Dn=786@^@`0)5bSn`tbD1x4Kj2D56XmMVSTS z9gye-Wy&aiUKvI9_jU@%D9Ri~lqsVqbAJSxQ~d!Jl2BFY>?AyY(!u`v`fMO4V#7eVIqOJtNOe~BzEoy+CGx5dY$ za}33$Q$)q3QzFP*S2^--#HCY2aY-$rcvTfqlqsVqa}>p;Qyxpzvhs1oRduH*dc^X4;0Toek0hI_!*F|%@hj9TFQE`IAwQ?81l+VBs}fM=wbhl=H^4JLM-gSpD9S7lWJ*Mt1;Xhz ziYQY?QRXP3Oc_O)`y(Xgi%i9@G8KJ4dt54)cg_>yg=h@LrBX!2rBWhZDg}y5r9@>9 znWgKZP(H4>REh@UQaOg=QYoV1Qn@dJyZ=$ zpluXUn~b8iQABMrirV%^(3XE35!$Tn`7;K-c<^EakTwP&Oh!Og0r16xI5<=lpy(B{ zOGZGF1W+^vphrg7Kkf-`dF=_N-M%{U&k)~=S{}L1KwQ?A7g68R9+$N-6qhv-6_+)M zcv%xDE^89;vNnd|vL>S9vNnd|vL>P+5f|Ql5$;hYDV;h4l$p;aESFwqfHFrBWy&bZ zED&T$M41JG%uz&{GKw-s5oO9K`^Tz>LkP=dt}_r-;i)gE@<&Z6Q*NK9%uz&{ zGKx7^Ajp)6G7AKmqlhwP6lIPg%9K$wwm(7+iaG5B$PQTR5ozL=@nqxs-etYP}0%AcOL$RQWs8~=X;({trET|H3 zK^;S}po*wiP{&X#s3IyB)O`_TPEz=V(-*GUkDg(f%{2)qb6q`l`ax(+)hMD&8AX`| zf=r1hvp{7J?e3y#kTPY1lsSqhQ$|td{s=N%ioNdXw>q*W0cF}Y!Nq;6*Cb$Ojv~sG zQIuIA$drgO3j|}Mh%#jqWsV}slu?wqKk{3hRFfdCj>}K2)6afmQtZdS7e>!Radi|? zadni4S4V;3>L^j!L*}6Dd|VwxgK>2nLveK!QE_$L7eQODNdRp&j@b;JZ+o!<aY!pFW5ycFX4R#PSOhz%o_D9f`>l9F%+?yz{cP|l~i2D zY1+Xhu%XLwn{Kcwrd^>E8^uPV)go$oeB)bVud(*}#yD$k@7r~Kc_=66cgI|F&fgw$ zt-aQoV}JTzU;Fs4e(Jq9pZTMwKXr2J+ou10@2g+;g)52`JTz^2Ul%K|k_?udwk3Xd z_Kso&9YNs%9Wzes^Ha1|6O&cM)PxI)>UB+Q`)YZdee&>|AF}cSbzAmUO3{|xG1?<@ zsePZ%DgqxmW}MK6 zYON;pp<-&nIaDz##~~qb-C9jT;5w!zA#fd2lMuL$Sre$e*r{DdgxWO; zwKJKk5>V}(t4cuscEzypkE#S6fqxw{PH0)RRufuQF*TuO6;l&hRxxV=|8JZ8s|fm6 zg#I&ue>%A3sr|O;IXhpSZdjK~oWR2%;(3V1wvNMm31GWK@IbOGd5H zRB)kbjxuF)w3!jUZHamXkzn&JA!zlj@hxJC$*4n6WSMBNL6K)7szH%yBC0`=Ya(im z;Lhf-X)Y2`G#8mTBe?tUHQyWJ``p9KyjDZn)re+f)FCL$hz1)JW<*qjVk$*cgTjo6 zS|doaIc%B>X`;E1HY0qGb|qHez;7|issvPN)3rPGL8WhiZxMVIQHLPTQD)H%e&3rv_q%a%_GMIqC{sqQ5j@)*HqFtqY>u8Y!aDnDO7k7= zl(t|0;A(_9E}{-WG$R{q5T(he25~B7RD)#Vli3!|dB zFgha`ooW&A%qeRT(3d^zSX>18w)YmnR}pmxqA%HCgXl{}HHZ@|qZ&kCGHQ*W(&n&f zjw)qyRGASTn6o**nw513XiM(AZxL)2QHLPfk_|S9wq#U;IK48eL9`{K)(9f4*g(@< zoG|Bm<5&x)0tI+nl8=O(bYy2ySNWck=>gx?+5~jDU+KoT!M6x5i>N~oUCIURiEqzHHzN zm0rQ1MetQb9fIgfHrOEgl2Hxf1k0!f(U*)`BdD}FY?`A=*&J171eLDD8g!MstgfMbsgPW@Lj6 zq8S<0AWo%>Y7ouHs5OE#o8G25N|Vh|T1Ln}w`IS2?D_YF$UjldfZA-2{2~o~<9v&t zt%y1VQJZYALDVLr8pL^)Q4OLt8MQ_*YIE2$N29Vi8qEl4_{U5`FHI1+w!EUb8Zp;o z)FCKxO*Gh`$Tbnwphyl8)u6~V5w%8;W^>py7ilP(i!_`Oq~)3glxF{Kk!e#M0W7Np zf|(;Cnvo4Qh-PF|gE*BkszEd(qt*!0Yz~{|C`~qJf83i~tesQE0#*%?6$_~HzP-y( z-yGi}s4SumLG&dXY!H3Ps0ML@WmJRcOGd2`RN5Ri%~7Rnjw&-k{(a)Qe|PYm?qOzm zo8@XmGcxKB6lO$&4GJ?NszEW8BC0`QMntU=c z64002Hs2!nDxwZS^d%c?5Piw0262LARD z-y)bPq7FecBO7cG&B&+*aVlk0gJ?!Ztr4VI@rI_kSW1_#k$S<0SW0Jv{BvaA4Sgwn zGB&^GItB2RZ{DC2UZVSY)( z{K^RVWzG3BIM1&aBENE#0{W8M=vxF|MbsgPzGQ<9qAwZMAo`L~4Wcg@wMJ0+^-w8e zs8S-T%m^y2IX^kM%CCn?t6YG0Y<#CYec68b#TWRi5quR+de**Tu4;fH2G-W?f6S@|HY5HoZr+~C9N4a9 z|8(4nss?A5)0X7hKYQcihaUUEiyyf7uJc=0RyBBLu4?ec^ZzHR8od7Om!A5Q-@f^+ zZ@uvS_?71e_WqNL8hrKjzF+#<#eIJVAF31o|DE^__*3iQYs#7iw#VN-hOfQ6zWeY- zO@oIIBG>m?!!|-ZA+Y1@?as$0;CUxL`;m8nqaVmU_G6TwA2|m7Kx_6Yoc9BXtoE6+ zAGV))F&~i1evA_IBgdd0Xw9}-9=>Cmoy;4tE35K5_NS<=0<#Y8%Bj{(yN5e7;wTfn%F$41A1m;ByQ* z&P}iUl$4uZ*^#`%5~l;-^va%$BlIOl;o{G>Z2ILoHW*!4R^NEj_46lVgVB@RtKz#g z<6}GDF+qPE#piYu@sm48p(Ea)eVO-;$RAB+N93>ivLoXN9m!Fcn5Q{*-H6`d>H15#F!n$hhY@)!!So-%-*1VRDI0DFOi3sxlMG^-&hVYqljYW zD0Fgq1)3ip`3f{UvR`b~-?wE)#t}M_qaaAUs>>tiO%|8}e=n3r9!EHGj>3`iE2S~( z^XCwU^!FEI)<+R%eU8GcPcK9=9pnp<=tzDy59#2qH=-M(0O>Gm$C&eLej#{1%VW&` zPFV;ZMHDY- zjzWK?pOm0}`I8bcG4sa;9r2elVq!)SCuWX6b9HApQ3S-ZYb;JAebLh_y!}}zCTsIWuMR zzWWTuy}pj@*Y2ESP^wt9W?u>pAp3Pa<2dG~XpM!LRS)d$S`pKcYCLuutZ2=i`;?^W zf&F68UJUL!;U1}d@cnk#T)}jCkCc}3W?sw@?`onSn!tr77?mRQH%c%n9a9ndQ!%tO zN~-bDpK6W%G^u(`e>q0oN2V(q$0FCT>~sXKbPUF(2wja5j7`T>gsxPK{jF%NBh`55 zN*#x;G^u((S2+f`#>IdC{(BTBz4@+dbH&ovTs(O3s`H<_VIzE%e~WtPZ=9T;`jdmX zfN2<=zY!oWaRa?0bgg1IH)|5E#|Y;}#Z)9*uVcdK7^%h+uGg)FYelMFhwC{8uK5__ z6~3;{k1;@S`o1+ig}FIBpt$K~<1q&EnvX1g?#AUe@bnG;ySDLJ1>NVzD)?03=|V^F zsn9W)MMe0j7$ulR9a9m0DpU+V6{DmY4?h*EHGV2Isd~*%MUH`cSg_;xr(%A_1M+qw z4^HIm)2HI?{;c00?oeHROoOj3S0iXGqA(8I6^RaHvQTYBiJ!#|@S^1=q#`1$f%)Qs#Y!PG@Q5dvDG&YVf=pw2_lqsVq za~zf9qD^IK{_o^*?H>DQ8-uTcM0gm{<}!UsIh7DRUl|95QZla zwT&YTuZSuUwaF-I8%O21s7*FVZ3QYjPi;90+VcAt#%xlo^@TQneWFENL1h$%CmW0v zR7AB1!z&srQLLciSh0eRqHaetU)D@@uSE zLH%Wu7Qtx|g|SOSapMRBETT$8r!tC8$5A;hI+YF5X@SblQ+JMnwy#~gzyl0_*9UIo zD?JUs){^XIgPBsV&hai0FiUv!>Y?4vTrg2n`i`gU_WHuG3>^!q6M`49@ zDfTVmuy@O7Ge3>RvgxmawFt_JC`^b%*7Ji9CZuSvL=-KfD0&=~^w#1 zD2R3`)}V`)@8{82ew_|7{r$WaK}!*ZK}$rL;|PN;qDn-WGKwMOOsf?o2aa4|rPGy61TA;G? zbef}Z0?lua(oKHR0^0m#&lYioT;5=qqA)z!V63Ymszn%H(O`*UT@}ZQb#)Y#Dv|yBDvC~JgDon@MW?bsIxSGyLpse-aOzU5hrX`L z9WXR@V^5~Fz6zxL^cyu6phISTU3sVGG&95S)j6ql$oQjuI61t zbdxVyaXR!(KP`f`A_~Kkh}y;xhF3(Dh}vWn-HfAhT+}8Tq_zT;ou{@Og&e%PL@ABs zjZ={6JF!~CljM9Qm7*|c*<6W+Tpy5|y2gv*R2EZJ%SsS>L5cH&*g!aZz(Mg0>tsBIH;Rh0mfy%&~EV&tef(BIcNkVvdcYa$L+Y*&uVQKxOBdV>t>kr`_bd zO(S=c(^%el51G^EZ!Vbgbx=ZFFeT!`B2X-tqlgQOj4Dwqm?A0`%u!U1D;7-AU@Vvt zm7R|TbB@A-X(das%K6@Vu8T9@01MJlA$QtDpVQ>7V`bpZtRx z&-|b7`^`n2f`9lfR@fY`Noo~EZ2h&}@VoCvQ44^}%_yl_j=_L#~ebiD(KcK z!lg;Y*ypEc%|25F;cBK@TNCV$WSxTRZJh%8%RA(^)G6TEV&1(w$M9saCUHU-BV0CB z%pruUx@xT=acWt|(4U$&`cov%M(ftr1pZhpg8QN88n_@gY-PD+Uye}F^~SXG^~MD( zV81+_+5)!7$+@1vD1lQIL#Lwz=5)*<1m<*W6`@lVL#LV`Cl#T&Z3O0E!D`&Nx%0`~ z`NfUzyl#aHtfa`r-<|6jM3Dn*ZJ!GmP;Ra$1?{DAX~B5Gd5G zRfKX?4CQJ9xr$KkHUhb@V0Wcl?p(kC{%(4CZ`5IaxTwSY7b{>8pNi$uG8Ztg3!}wi za24UFVjUBoiZMd>D&`PE_o}su#HV5%6Q2r2_^DWv_*85m_*Aff0Y4RU0R#9>#R@1e z?~BJbu&av|;7nXZ!J$NyIF2Bzh&l|xSJ7aJ=u$>eW`ST+BI?|T@J-vL*wZ|}ZSoVb zsaBLQh@_lvhQaBgguy6cUW%wl%u&Pylu?Hv=Adk_M3J8&DiTy8W~e}sqMH!D*?pKb znSJv+wdLP#g|mC8A6jMVSSHOo=FSBZ5qydAot) z>uOi5fKKzicsRYfSOLzJMHHM$M5p5j;)t#jR;OBDb3Fz zP^R51+FhSQ=G7$(cnd;CL8fewGRF~&6;X#F7%Lhq5oO9K$}A9MN<^6(5oEd)dpE}u z^mpUJCl@xL%>9x`sj$H~f=n4jnd1n?im1a7j1>)*h%#jqWfllBC8ErY2r^xY?Fw|6 zU10+{&3ovPpjQ_*;5U=`26~EuQ`sP$jw6UGq7Fk4S2S26I+am$S|B);h)y>mT*clmlTpmDafCD~q7FkyqoTnQF~ejOGpsC>jyPPLwhPn}M0*5^_Ov8paFHB+er>*r#@y&7><6;ZLOjw1My-N1FvAqc*T z21^vHs^~mb$_DKYPIatURYikqgjIDabwHW2)B$AXoieyUysFeev9I4=Db`IvqC7?e zaJe@>Sl#uCm#t#}SEn+f0MI5QAgutnY!%0Vl}!M$eA?}~h-coPM&{)L2+X{^*B*=L z)ddiEBW`{@Aw?ncWP{ASafI|Lq7FkyucE;cG4o^;Gp|6%Jc*cI8xdqqKY7OsSNW57 zl$qZVhL%(oVH`oGjH1kO1YG21`VlGKw+_1ep?1=0*gWF2ycpJu_ulgg7UP z9}~12e%6t^IUkV|Q#RNl7%QRPd{_Q&lAsW_EI;7L-JMWD>Q10NsstIHx_$t|KFQzFV7M;LSwbr^!NqQMeTri`M@ z0zsxkl(`Wh6?0hxYRkWc3T=zB2+LPGu137J5mD48kJTc$DWVQTa8oo`B5IRS)K(x~ z+lbkqHW{@>(DngtzV;3EapGIvMOYL-7)3nsi72|+ulFu;jqrieNW!A;R%iQ>d3 z#uX<%iFo1@h;C#z)(F~isRL@0`~G?HIgTJeMp4^1f}0}hFa$S6gC(Li8AWXcf;Nfh zW+Q^OTXBAiq$Ve}L2sEG|e>6@&tD5fo9BDWh5hV@1?q2*!#AOB73|7*{Nv z5^?DiDAvbK2r_-J8vYlkfs9?kRJ>xaY0}0-n$;pATP+keM=y#>Nqh6;X#F7%Lhq z5oO9K$}A9MN<^6(5oG$z+kH8gDGMY}X5I)5&x-;H%Xm zC8A6jMVSSHOo=FSBf_Q53-|dw8LxK`3+mF%)uj-)*2*YoleHCM)f7=Jf}5hj!w}pQ z4VEYtRMGiZP$lAmDo`w_n-FqvlEO!ZUymXsxj+JB=Fb5h&KHfJcVn7!bAzAjeXH1bbxfTU~EPJp{XF?%N}8M#Thc+5eCV zB)ro8aLwu={50z!*d}WGgS~DZ1rqGprTxL~Cfv3H2|x7M4_^Gh#dn?Gys|*TvvYxj zH=e)$R#MiKf{WF3R~JfD23 z2>R3nJ{6%)MVO13#PQ)X{~fF4P^|+>v;i(8@-%`4RTHQhBUGhgYC?%B#{O3HfD*UG zU=lATi7J8;HK~seCEH@+`1=uaodc%cew_nIzP!MHU+4dQ{NnOEZ=txwH@-Ff&mM5U z?$rKraqDls;^OT5(hDcO>hk;o1uwpDs)_M^epbcSd434?i!hMjL!b#h1Y?A%RZLCz zAy6?k$wd$NA=nlJ32&P|1S*0b0!`}UL+Q4dIR1V_lr^wX`pR6EHHc++&R~2$_8s)G z4ok%4b_`LIEl0c6Vt1?t#bPX?=ukEo%kictT>9IPn@+@vESihuRiIdt1zJb#Isun? zOt@|r#S0?smNyo?cmd6wrF}7&=m|0LWK@YF!9-MpBKbsAWS>BhegdtdFljcfrW28Y zqB%N{(?oL;t)q6GU>43tg1KC{fXeK<-y#8gk35wX2wp}Jr$k0Ih{|LXCto5elV}}< z*|H%wouD$=94B8QDwAj(wd(|wM3FLzN+hC4iPlk=LmP6_ z35t}>QHewpDbYG=*9nNsF%UTwDxgSNs31<2%O`EVCq7P<67f_yhB#6A2Y8YFeyj$? zsZt&bE39mgA|;~8j4+4RjiwWEsuZ1|lHA;GRB@`@6t(LFMCRro(kIF6`U=|>DWFfe zRh~Wz1fQdb^ChDiM4vK>W+b9diPlk=N~_Dzbb>x*bMz?@eM+>B+I0dxa}2IlbAbXV zv;OQZI}qpv3LvZict#;33V^AQ5hZ{o8G-4R0E#5ok9f_SA7m+j;@?{%x*<%mJOac> zuphBI^zz>$#eB-GAx|+`o*>rB<;!2b6F$~TiMUpdA%1rED}ed28Wd}#JQmkVfnu!` zh`E)!flsG(qv-_nVFeS-F-s(hwQ^I`t`qx5f=Hhv`>x?4hf5Ps@_zjo-vv*}1%l*J z#7UG<4WeWjMO_k6vPA1B%&!f(=>#Rq<|tVrN|tCHwd({V=NL$y$`bI(MwTUrY+ZgS zdj4ehBt6V<6IJ-7a@LTc1Cfc74r-3J~md1$dgAgA-U6t(hWlf{_sgz&y!_67V!BkAZWQ z06a|!0DCzC=PGXbU;KGDZkWseK7nOJI{<5&B6f#f{`*}K0@{*A2$**J)efiq?tE4? zj~!Rau@&Buyof^DNn~R=05R=kgG?Zan06AaqcGz(uC{+j2H6}FNFt`4MC+(sC-#qo zwDVcF{(0Kv0t8Uv8{R2$zZ~#{AhJMEGKx4+GO9roDWj-FB8rq~9fdixAvc}i`YD^E z5{W2MqIJ}+6A+nWkdNLwo(5%U0&3i^RO!3mc|B7gXdFeHCK=TrYLrnFA`vx8w2s0I z+K`)0P@`;)LL{O_iPllOPC#RhK|W5kBAT)Uv9Qix`;e;B4tOrC5^;$fL!2lRv-?uI zJ63~YVHHtSA{&f_byF1P(1zS}f+FRSs6?VzSVbq+QM*oXVVz?j(uLU7sxPdvBteLr z-!a7G#_o6fY*=0{=6ymGIfi1QL{x)9q=*WU0)e~wh^2LogGk@(A4{u5Tw2EvCrZ``X0 zh^19DMl_1-Q+){5mH0x`l@J9A=rjL92z>fp`1tC-ygM-=_$;DI z#QBm@4WdsOMKco7r$p;0Or?#h=>&bs=IB!*`jluLwd(|Y<{0?&S+{#go|v*Y0Y%C! z@)TJh*cwHgC>hltij+}QA`wMOw2s0YT0MrQ6BH?%qY{ZIQlfR#t`iWMV{j8Z7bc)G zxjmlB3It`Nh*Kh?8boC>ijyx9l}WUY!faW=g{Bi!CY$5rOGIT7t)q6GfU+C|Ww|Ip zoc@+y=eDY8JYHHtVcc?>nP0O z=R%~6p~wR9;mUmBfhz}V4&T=yWnCM?qYd`OOE=1-q z;_{Wa2|;8LRU+DwQ4OL<8AT-$QKUrcD9oXatLX$q%I27n5>cc?>!@8PATq~5q|dr- z`f`!7XhCG;@`YC4A0HVh5i@cOMcRp|21Q1SsK_mWA|nM_M_~?a$W13ABSmvmvR}~L zXD~8yQ`D{#%*Z*$FJQQn*0;J-+5V4+ObEGGASfF}oDvz;AS#nloP3F>OrmuZX3K`$ zbb`ucb5te~l}WUY+I0fTatxF`NoBsFoyx37i@Plog0ccZ*(lA-tm|iw}gz<>}We48)fQi70Xm#YBmy z28Bow6-opOkpiuwFo!nerV}AjG#5$)3XuY>qjsI3$T!@8P zxQNa%5b3jSUt3(HEJXm3au%Gh;?S6Sc?9T^U_WAa=;go1!$J1QHN78OgK-8-r3vi9)V`tE&eYo&oVcmV&xx)9Y2&Mvu^`26H3-+t_^7jLoB1Q%D9 zCioX~X@c)L|H+fvKmF(?8m8|MlzFuyyi*`9HV5 zZTjE$zWQ|^{x5x><+Y3Z{tk|R;{QMXcP>rv+RHObQriT-JLgAfg4bSNA0`i%t#zb6 z4lX)$-}Xst=eL3hzVQ&ZwXxUKemUJXi{p#uL-q{nzBl2kC&_i+NQ!OboCDiTzL*0N z^lglAq$;K(^>Hv#)!J)qG@>#+|A$bez7N<)~_TIK^bR`c^=YyBcq zdJ?m;j_C;JYaJ7_vV~y0+z!t9DmNoXLd=cgK9O%mj*J*X9GRUJcJs7rutm%Y5fy2% z3E`4t+DXk9VY=TU=h)?rQ;vNUaey+aMHst`;@CGLoYC{vR}OFg_b_~?C5JbPI0hNj zA`DMPad;aM)K1$(`MghV6Xl@oT5a+1^IeG?^eE!UWK@eVXc@&pZ$xnV%(V-Q{kdzl z3OzhM>wGSD;yuTmc)m@KW6#xMka`nBe?}1pD5F}`91BanY>?BE5yqZ38ph{&xe)li zw)i}cA&$YSl`TFi-e8OPc@|OedESIDy!<=CF}&q1DBtZB!y7{}1`*XF4o^hI@HQa~ z@5}t;eg&W0v&%F8^6{1UsJB$lCckA3nJgLVxn6sV#^Dlu<2ej)hOIY%mgf z?mWJ4`KB{IY5Q6X*|vJ2^S-?(hP<5239%bvD8?nCTGSkiLlzCjkY|JyYT7o%6R+Gh z#X;xI|Co>WU?T=U{_-g!HP`GLA%$SC5d zWmJnWAu@^+vJoNaKX&3Da^K7mzUR*ghBqNP7(*ddM75|n7Cz*8k8SGVKEm+Cb`*>< z-`&RjD&KVig<}ANkP!_ikFnk|I`wJ^@F{j+x0(D?EH`sdrQOt8oGg9s06(dth`wZ0 zi=a|Q@sqj{`4!cvtvfc`KIpvh0Rnw@1I^B-^8_&KBY*-$L<7oWK%i&}Gmybh>)UB@Rz3i^=l3>ClrKE1@tD=7IAZwOz$vE;un7Q)9+SQSw%qHqxv!Z#r#aem>I!^`hi z;`8i3x5eR&B91{uwFtwLQ5@bz1gGA4I~92ne;{5^%dd=b(EIQE`x}ZJ^eE!UWK@eV zXc@&pZ$uchcivv_^FhnkDh;5uX}?nV)n{o@uGJV!Us9Objab^ z>uif_M1RMP!y82$gN$ksh9{%wbR)v>UgA%$`P*V)Z@%(Eo=%7}I)*}#h-y)DEZP$d za^(3fIDBNi^R`y|M^^sM4hNmzox-5~Wi~n;MI4!oY7qu4qd2mS2u|~hUmTu2>|T6i z{XHWNZxnG1GO9%wo{Zw~HX=BkUXtRqgnUUV2EDvId0~F+Yp-T3=Hn_9BNI_A;-E!T z40;p7puLCoj)Z@Zs?t6H}t4@)tYZAGt2y;~t=Bgrf4D)Us6S=BL z6%)C-j)`2oo5=ja8FRIK;Vc9$zoPm(TVYV3P$&_7NJNPeHBnR|qQav@WmK3HQK9m# zL-=|zy-3H0NBKoMz$fui$4z^k^2VF4+yBQB*uPry4F~_-i&%o^>wMffa1?8>K(Po* z1ZVPXxI?4hOg0!RvFL^^s8v*~#Ud&eg0`obZT@y6waM2CsZAnkD-Z+}2-*tNL_vUzqP7B+QPd`* zsO>I9SURWQ$lz_8?l&^%G`|yxRGfa20n;K8ofZgo3IwMGYNE<+(5Y;YE2umP{rrVfcH4i2~S;7C&m;8g-x zy=wqg)BK_|6EN4h#YxcLHI56L<X`B^M9ZJ=g0YfeuDq!AMp)ue@mT88DiM_y2yzMpr3GrDAV)?~X@SZp zDwR=GdKV&`0o|89?euFM`8yeuDPM-COo=G7K+sYk$ShD31uZg)G7D5jQKpQd%)1al zrthJMUjY-pm4S1lztkV6&*c@Hqd0vE6sJ##V8`|fEWV6*gNH`Jj%+YapW;~jG9shm z^eLj^^m*4IIGr|OFhS)e49d(KMBv#sVNj+-)KVbGED#d3Kur{6$|%Y#P#Hy;GKw%GmFMATLw2q+LQMCZ>umSNvi#4ndcRM`z`lTkd+NknZDQQKXJuryA;i^21p z{9TMV&n+L8`OYB9%x@23?j2EPfq2Fe4IUZ=nete1o)gESOc@pDIT01-xw{TQ=Coal z4~gY=tyoZ(-Jf_#l8qCv{sudnChiUn0f#e#a*A;_Gh z@X1uS4UJCsx5@iXE+(i%bXp+TDG;0%sELA88AYcBDx>IBM$zeAh;WkpV{RAq-ExtN z%Ue#4s4f341J+eXk%}UUr@Ny@F%`wJA{8YnqaqbWRHWivhoCKQ5RB8|^0u_^v5V88 zKyhM|2zisYibJ+HcxV&^$OhwdD7q1+LxJLSC{UaZ?>dAPblUjH4OMdEV=SG^tu4NH zFqTe%V(FBKi=aeYIwfkNxCn}G&QXt4I zP!k0$GRpo~qcVyzWrLJ?7b4_f-hdl9xV$yvdsrg}1&SP$h}k3&b5NotirFNhsBOP4 zf_I+hL5U&W`UrkK#*CWCJI_)6lE5ujG{~#MVWUY!rAfb_g(DvyhB^Q zBfJBUCIOTc0FUVAYw}%FheQ+yhbVarT*OEKNfJQOT?3F~{v#u{X~1(#{Yr-2;8S1JMt?T&&>r0 zzUTbuliR=g=p(<1U&(m(MJqt?#7kfL(CM=;efBp`KlrsD{ov`Jf8@uk0>KZz_lt`P z1W)f(Aiy3zltg@J+S`6Q?aG{gGJ{Ku?S0>UZ^G_N?1#74@-4MT2@+eya8c3(tBWR8 z4|tPO$J9Njh$%06U}uhD!gWvejb9435tw6Dg42Vm5@7bs@4_r9!qBWHm?K5#PZRjl zr0N0v>6p3)6`?=X8vTtC`cpCVw~f?Q3DnPl(8wske5e?@(gdzFfh$ewn7RiQp(}M9 zx*8*NrDEu68-c6+pII@>HXqi?B75+IC3H1PFdr(0t~9~CYf|+s-c?xl+V(q^tyP4s zR1fHCjL?;evA-34TNA8{uwa)UzAjcj8}qQ8^VQGBbQK8Zdxvuc0?hP&4Gq>Ns0l`+ zV(3~ExYndP4qfY*x(5}ZYtO8Vr|XC~S(T(76fWg4}1` z-eU3#@?3!cU!tDeT5AB4DL47Y;yiz2!tIa0Bozu`jTW7crC6X?i^ou`#v&>f-AxEi zeR|`!%Rd~2BQ{qcfK0g^pE3)CkH17r&;lVr3siQ430g!o-6#=d$_6QO6j7#(qRfp5 zGN&J8;;Ve}2bs9q?*DL#Z-J+=afGRoQIuIA$Se?K7O078x=|v^l*gjXQAC+CiZVAM z$aE>z4PR{!u1kQ)G_$)aVJxXr$$|!9#$*(o76^6?a5pY`%jwPEVLmH<*MzuLG1bTXgnwgB`@L{OFl7^DP6udpT- z5#_-PKw0A8lr;h1Bxa?d-@v+zOjN zKHCtcQASa8fgrj-Wj83gh-$h~B8rv`QuHXIXcx%cCIhJAozB6 z${a_`3DIC=twhXPiI}w#HBn7BN)%Zujulxuh9YZ4RAlWYgvECLsY4{yqME=m-==B; zn6~}OG*=_cjf|qb0>MLp%5E?fi>Rg>C89mqAnlDJ+LKYVw-LeKQ{2<+`_Q?9?iWj# z>IQJWmY<5N5hq1Ng*J((O`@_Jp-o0L-6&CL6Agy8F%;TFRA}3TpzXO+|CZoCKgkQv zITY8I4tU`?25^ZdBA}}PxQskfM0xN6K#@E+1dRd6kr7a{3G9AGlP?CzpV4H7*=s(F z)7n%u03YpfgwL;xVulq68CIaO8_cjGs_90Fm|?O(X4oiVhRG;q*hYkFl#t@rC{N%A zJdk&)mca5X=iAnKjj~_i>1u?jl2MddAjm8bWEQB2YPwM(%9O{V%uz&{GKw-cBFLO- z3GkamTT5VG$#69@)e^ug<~3j&!c@s9$}AAH6sYV5GqZ?lx=|v^lnqkmD56XmMVT8B zWV#f4Fz=b!))JU+UcuP3ZJq1eD8f|bpJUxM7|X0^FcwUS%5Jn#O*cvu%dF@|EVE-M zmRS)M%j_nE1@#MOzOH_e+t0l;08f%~pFG#qafG>%QM6Yeq+)?!uRu*y(~T0*o;()q zjUw8UQM9)aAr;-1ePBHm<&W~l70>b}&{Ru+IWUU&K^IYxiV{J~|34K7><(6StVl)C z!$`$36sahpA{93wq@t=Nu)I*6Y6(D_y_LVXhP@hLQe;$|4n>1;Vw0%sMjO?1qeO8! z6y1o^;TVe3p@@pp;U)xa`R9^j>0BjS)&M(V!u|rknIh|(kT%aL5a$4P+M`V zrW+-SrBiexmd-I0OQ(p6rE?R4HdQrXdGR?_4d8r@B2J2k3T+Zmn?z+dLYs_gx>2Ig zCK?QFV<@zVsL-|vL0kSIYi66ZzDSO#J^K(P;n`CVdEDnFU2p-Z>}1^5C3vWo(c*yh@Wr~6*(vob5Nr4SdoJ= zs_90FA_ql-k%ME1GG+0C$U)KI8u^8as2Z?*&0^Z1j``ZJymmFhq{t|0D-g65sO$!{ z6;Vw$N)HaH!O-51MMuesF>K}^ddC&&2I1f(s0Wgc>2va4aD6>G2S)j5TlvzYI-6)YY zRds_h<*^z>nKFtpHzLTK>I0nnd2qZw0A^8cmXC+>%a6cclBxpy!Y;=}nG(_1D56Xm zMVT8B7F4SbfK}7?qcaEhf6C4g-HaoA!etb5ut3Pc0^!qBpeCy6Mv16R9*f#W5w*!E zYTJmAgD%AuV9&u^Kj5!_`(L|w;KDEEeK%sNAHeO2H|P2RQBYv2A7Br*EW|#@>-qty zT);z*eaFpj+5a9W|MMHUT)=Of-2T*~k9-Q{0zUYe-}=I}-~7mrz4yjb@A~a$PJZV1 z-~D^n{_Iyi{@xou_iujwQzy5+ZTjE$KJ>b86y*XQEXxI0F$DWyJbZb&JAFjC0BSrI zp?THXaf#J=D92L~euz}x>_JD_T1D)`Rm9lGbTgUD1)Q|y0_e{cpoKpa1)#rU5n58M z9hcCbI!;aKPshO07@Nqu_D;)z@V}!0$3|(y_bGZO+9^Wq)0GF2+ z`0wldzmNNWzwaUn0EE~1w&6_Z^*AK#ty?=T3480~)FiyB7ZBtQzx4FMm8h`@#e>TH+p>q{IBBCnU;L#Cv$zwH$DvKz%97VJ#qo{Ku!nO8P z8h~$_cclU7)Ha;&7F&F^@?Ge3dPGEVvcaPxij&7`5SO!XPLZ{3lA{vto9v#t`JXV7!vxtJsQAC+CiZVAM+{1X9TeW;sIknlW zEwoJtXT~EUx{(bY9np3$t!-*3)TJR-9G z+qB3AkB%r#9;-ogT13I=D56stMW-7PQZaA$kJI6D1@-OzaXLI2q8quv{pg5p0J1%oE=j ziW8rRiWA=^gca1K*j10OpkI!X8*>o=Tz>kdb2{BG&@v$`ct=FEBO5$AqBwc12GMB| z1*fBkPGuCGZbWc86$zkIStI~5<*s|A=aB$R$)=77U`QUL1Q1t5+#Cc@CYyq=O#pkf ztZ0L6I`H)SW~5&(91v^ie3K0+>3h^;Ej=1ya@pgX#g(rg>*$EdC6CphSW86@`Glfq zkjt!yinUarSW7n{hoytw^bb3TYak9aqBZ`y9Y7m_kQM>|Les}QV3S6XR6rD;$ zryCKR`mrpb-RV>KX#jjh$wdZ2=JI3Wl>|cO(NGu@4IUkZF>$O0g-jVmnPVtqil~se z2|=byG5Id@xsaJl4?w0Jad*|SD?I>}M*=Vr$ZA$g1vKwJ@l%UL-k1+aguBOpvR zwGVcs2Uwm=mL6c<*}GsnS-8saUGB`gBO)f3Z1Cua$t91~Am&{Wg}fU@kXS@9?_`5( zgyk|98K5@n?n0aIXQ#F!BD#?c9v#t*JXVA3-`gw~QP4Jus7*%E%|?Xfaw;;wJtnfq z0A<>#cp-Dz&(25xM?*9wk9BlJWAa!HvZ*y=MHFO?BFdCel(`W>rc1F0&3^8aMFwI) zT|TYyA_K9Y9u3i$tUqvcL}T(;4Wi5KvJQARP}#!#f9h>BF)gpi84$N;s;ngG;xL_{~T!J{L(k;iHfwG~m&Hj1cC zMp4^F1Z}y<0JX_|`qXwrL^ra*qa(VJ$7&F@6;aSOil|LSQQJlYZMn!mXj@)kdXa(9 zb~F@jM1x01;YJ**L7`1XQQH^_Z6YePZ9+KXxfH8#>ldG&i{0wE^gzg5uDia=J!BpY zg)!0K(NP!^$7)c>lu?v9hC-%@3YnV_WV#f)+;N$*^gzg5e#iIv0wMEgD2#~)kB-8a zI97u~ri`M@F%&XIRLIGcJYUG15M>?>g)!0K(UDD3J+2akOwmKTAyzk7qL3*X44In{ za_|Le^GX5CLAe2*OXm?0-N*)yj;Ku@t3lLOM8VA{qBa>tZ5t7^<&3Cy| z+Yu4n$Oey&=tdr^LDW`6LE9*@e_usWn{056uyjtP2l$v!l^(DK!FRyDA}S5oYh7Mg z;8qkCu%Y|ExOsajJz&4PYk#n9uDAc`jj4RWx&Mpv+aJ4e@k5XO;KdJIeAoHar3XGW zmmYZI`L6W9>(74asXzH2ZvMtc{@3qUMGJD}f#3cv->7%Z|9j&9J@rkO({J9}EIxdB zvip!mk%5OV&ki8hiTIHq7&B8|JzHYBTCyTwb0#xql+}yJdVV+3WmEG_Xtp%cBIn zRx#{#CYZwu|IFR=eqLH{UTg0*sox972p>0oefmT^K8<64qi~F7{&o@@h3Q~_yD~Pf zc7&r@w-%$BNo<9+#jM{7%P#I$V)tE_?8!JnFJ%SX z8b$1sh>A|l2$Ue%Aa_r+cLLX%m`a9Jd4d{xEh*ZeU`k#t}Ltqu8m8(5bv5ioKCLhuE8Ogx<&~_9i2IsC@U8 zRZ#RBNxvJftmo}N(Z~72hml_GKvGp2z~T!*cFxcQEuTtAM>vj?0(J>Q#=Z; z>j?BMH-(=0k4)N+GkMSCjs?!%{%@T4%khyg^AeAVy&5rLL=iVks}UzoG#C>%BTQU=mzTZSe?8b=8)a|C5qcw| z*qe;-)}tLO-ZqvmsG^7V#c;Qr>|Ri1vW@{HtB63)GT6P1$wfrIjmepl?*vAw&eM+E z@E0{(gqf2Ka^^CE$NZ8Zdt){3cJqWo`ul7=neJ)d|Wmm zq~m`5kE;=TBN~j}%n0e|4PNhF{tAEd#{o0uFMoxkQAB?tD*Vj|GbY|?VyE^uV)^Sc z?9@0yr(_g6l@U7i$JFR=-oz9wKU5QfM)_+TF?%wKQzTGK(TvcW{H_UmBj0%7A;%GV zBcs@xjPRlIU&ughp&M_yZvUTYYeasrf_;>a_u0pBgg(kB_Aw*OSbkq1K239K;3Mvj zrsLBziuh>~QSoV-5oT<949;a#J_e6I&fd{Sf8rW_97XJ-h>AYW2s4&T$cMlAU!XVs z$T0kkBKi|i;crI%iBAtA*&F#djJ+90=#7kGZ!$t}^7A$JX8#3Tf0V=Cj3e|$MzJ>; z;nU>DiZ^=YW25M!cz6+g97XJ-h>AYW2s4(ShOjsCo;-Uqj?fz!#olCu-sGG7>`lJR z!Drj=C$l%>2)&U}>`g}S=f|?m_`ZJTd)IIM_P6+bXM4xOZ>sHXRoh)f`@^;Q&a^$L z@Nb~^`1qNfq$@DdiF2uk}~dp+o#AwUV+=)Z++}77k_Kh&FtTtZ)Sh@ z`EQ@xe*V!%o`3YQzjF5MAHMJRPv7;gzVxB9PrmcLUp)PLFTLyg-|^$0z5dB}e)I!3 z^S$i3itcMJ9=v$f`7hqMyk^sd%ilM=;SF#8wu}4zD!Li>x^chm)cX1AvdFI8+OT-L z2fe$*i~HXl!3+92rXqG>SHyIrYR$%5w05r$n8Wjl+pDJoAlHpNXxE{*i;OD<+-1J* zce_zum*(=Fd^o*|V6JscMQBdNbfjvH=2UC<3Q^Bf=*pgzE%Fb`QAaR`I;J8tqGCEy zwMHYVwR?pi%V5FI6`p1D(*8w0_?7RkSP6OQZJeYmuvXy4Ddm&@mOEQ5DmXsx=x_t=%gG z8M!LP8%pMnI4(^631o{njWVi4p+!WssBAFIhz9R5ge!QLVo$aF3O*O(1$+_@hc4_Y z2)9R`jE6({u`YGm`zO1!glT`&+9J$;5mh4Ulu<1z8>CLz;5~-$&GEy0rsoxR=_WsY zhPJB_rm~1C5#7kB7L^Usjco89L&!mw68lb`jvUM{3PhsK_o5-wUleE&^Fl_IC=yLX zwWw?`5=}ICk0Ho(DRyha7t~y$7w|c(wy<Mn4y(^V=8HY5!fUw*F#9i!l2|REell zMzyGHkUC|9_ZY%zn_p9*oBiL}^jAk(gsCi|N<=p@szqgkbR!$Q#}KsTH+Sg9UYuEE zo4-xdB1~lwRU*2PQ7tMPq#N1bJ%*r7l<1|R{r9l_RiYMQMvJHtQIU*lQQ06B$p-H+ zgcDy`q8HaO{)!ci*^5?-RJCQQ07k$p-H+w55bERpyt}VA*^6!|8iT2wX|`6e2?#}HC+D$C1v$8%X;ybtT|>%v%mcX$g}7xbT+DgaE$V>F;> z3XWt`f5`z-Ef?6Omib%Z?)~Kk6oqN+O2Vd0G{xB;14UDuebLlkazam|xaEh-y?pQ0Q07{YSsQtYO_Uzt2{Hoc0Q--xF%o6E(QKz}2? zMVR0sszfvoye@^iO+ZKw1|lyqe>K+C8AnXHW-;D8ob94wB_whbdw80 zAlrP)QHwB@MO2CCMn<)$Y>;kbgZCK1^@>Ze&0&7=DffZVSl*ijnV-PR;4Q)g7f~gm zF&WjOvOyY?4c=o2IhZ%h(M{gk4Q;-Otwos1BC14mBcobWHb^(J!FvorTP~#=Zk8tv z-$U3UPNj@0QMeINEh-xfH=@CN3_)AoY)Lm(igod&&No}O2vb=^m56R+REx?6=|(nq zk0G4#T#8MUpYdeTTpG*!N3nE1Id5eu5GHsO(U^>CQF$yHlMUWu2sxNH^-|k@IR@Wo z+agS55mh3(kx?xw8>AcA;5~+rgVXM3-tv^YpJ~jt$SxLCFPhsTOmGoZA{vuXEh-zN zG1=ffh9EO48T*3zLR3J`o5yJ^Z|KH?>Q#1Igb6O9NQ&~inh;C$5i^>M+MmBhlA!vKQl>?Xx57mPKk@JW@yXA8Np*##$%f=iyqjAqaapbq9iD#p!#MTviAr! zyP}N1RYivBPi?;M2u7u2DnfrMhW-?xzcE5fDyAb80CL03-m*J}*v4#yK%Ct0G7xhCfm}ivIe8Vq9O#&eL^1dI8+ROsPE~7kI!0(t z#dM_VHJz%~=u{Cp)dWtr5%@cISL-qY^P}kfG6ITVY}Tj6nu|^Ec%?@Zx>7N8r3hV( z5gJi39jSUvSE@C-QpEmN&H-HMn0S{;Y36`|`fLZd3CBXF%_Dni#PhORY%(QO2-VZk0ax$C!0D{4_%AeUjrm#9=) zpgqWkNkna+Y~qRaxp*hU1IUai4q!=K9J-#$Tt=3hCXxcti__ynbL0R_U? zM-iRMC_0shPDc^N$*2~U25Oo|=JjmjT`fy%s}7yGkzr2-0o zy;JikHANHv3S|TYN&tOh0Dfdd1B&iKnrsTnB!I926urVin;^;5%aD6$vU(B&ae?uxu1jCE{`@qadhgkjtTrY7lG`4YsK4JeNb+4K9ZgaXBnd z*+VXeISSg=SIe-FTc%z8gWE8bYL^q>|Ze&!8%5hPfY>?U{qP7A-+eQR! z`IiKF@geulw+O>4qDn+C5o8uoC8A6jMVSSH%#8>+_%fFRh!fxPZiAN|h!dYc zadsO+R3s|}voskl7#dO?Cn zMS&s}$57;(h-wk@O-7X{Qc*-jDoVsu+=P&d>mmX9cS-SuI~55i5XLZys7*#un?%$$ zis(j0wWu5yPlvKWYLkfC3IuH%5wzu>g^f#<`RfL>-I;O#)FyZDQ=3H8Hj3y*Mzsjq zil`D%n~aL97%{zEI*TY6%?R3Fu5tiz#+z@_;UnUu2jYw;P@M6`5Z%a10WE@?BC14j z#*;nd8BZdf@irl7d%4O1gtp~s?xhDpn?Rv$422sJ)gro)Q6&m(B8u5&Kh?VUChbFx z3zba>7b+fQ*F1iq@>2Yw_PR)bjc;+ZR3xB47}+SIOc_O)5>e(TvVULoutnv#c*c|6 zpiGGQ6-{G8AX`|Dtkzo zISLD^DiSc?BgeUEQ6xYjepbg&GK9ri`LYi70av(U^>CQQ3LQlnqj* zM3h+|$lQn^b1D*W?lNEYA_3NM>%QF6Pni-?<|v{u8Py`lETT$8nKFtp3j~=P5f;>! zt4IK~$vyqlCK0ubBD#@LErOdOszlT#qo}Pw(6$jln-vMbN#Pf_P+^`4S+QD8KD#)3y>MG`> zX$@c8Rm`;}2Ic8hj8)dyBM-m%AuEMYAD_9Q+OqeV_S%|f0H0PB!*o)_wh|XH%rg~Z zOUqsiHmjnrgO!2s&8oN^kIlws{@?3f1OSBtXfOXF0QwF>dy3GWBAh)%?2(@JP!ZZw z#P)F(F|?OsVDG-kUjBQGhV~SpJw@zUMsa-FQ!%us2<<6Cdzru<99R(uw>Ot{fWx_j z1MR7@0<@-@j_ z7DEg2jyR{-$-l=>$FInB9OWQX-m^h$;@dEDNy95K#`mR zMRp1l=_yd;=Zs*~mDm>|zw;!^IZ&l6DnOMIQKdwjMTw|VBC3>#DhmXa89}90m1sIa zl{p3~y%GggN<@_sQKdvwDG^mlM3ougn{Y1XKxua1#fs|194Jj9N|T85B@v}bL}?OH zT7e)fBS^E(H=Urg90O@SU`msS(j>A!?sXzHh-)4hMQIXIT1Ln}@1b?x^Y58hQAL3V z8nqi!3!`4(fkq{wQHeO2649tcG%68|76?W&f>G;y(+L{QF%aMbrcsG#R3aLch-oMh zjY>qL8NujObAu0NJ{?AL(FdyBuP8s&7r<=|iKtQ{&Z0zADG^mlM3n`C%8a1WI^T4H zDsv1}`hclYBC3>#DkX}`o#kr;ek`h#QB;``RC<)X|KQ0fOFmGgHN415uPzYRIRa6o zjN&XxM3oXzr9@O&AgIg;Dy{QPC#W*VK&20uDkY*yiKtSdxXuwXX#ak%>$`4HWo{5E zy@z)D*Hwyw4;Z;ub%0U(cRQau0G|@TrUaNq31CtJc$5GZ3xM5Cb9Z0!<_B2_p=b#H z^1t5=5B4O0I|*P;0(g@E)+B(l3~)K~wQK$b(Mts|&8%R+P78E9Vwy?BG?R#*aEX{^ z5;4srVwx2QX_gTt-8$cNf@zjxb^~UbNyIdhh-oGf(@Y|!nM6#pj3Dislr~ihz(a~$ zEdXP16jvtWLgN|Vh|nnaW)5v55)X%bPI zM3j~hq-`zW5Lq~{HW2Ey0wN0qiYyc;5xz2(`{Poro-)V|D|M zEEFiRP@u>{fg%e9iYyc;vT#Na`mqyVI-ftW+isL*-CZo5jwnqcN|T85B@v}bL}?OH zT7e)fBS^E(H=Urg90O@SVEf}z+Cbq;*uv3mpqBMaJFQ0YqSl{&v;@>8KQmu#R)v$jwf)d6gxB%(@*IExZdr9@OI5mgol zDl>vg>wMD*s?0G^=>w)piKtQ{s+5Q-C8A1+s4^p{j2^m5S+IdBW!V60%qB`As+5Sc zC=pdkM3oXzWr3hFBdGjBsLTMq3?PT`ct;|tl!z)NqDqOVQX;C9h$=I}Sy7a0h%}sU zRbZ4;WdOLdUN17#QKX?jkpTil8VVF?C{U!KL`=gO!JYN6=|rUA9J3p6q@h5Oh5|(z z3KVH5P^6(ik%luu8cs^MqRL_ok%r5YqZd$!G!!V(P@qUdfg%kBiZm1`(oiC%;f$ct zI^T36(r}J}N*{2fp+J#_0!11M6lo|>q@h5OhBHDM=28umCJP2onnaW)5$8)HN|T7v zB%-teL0U$TW_2c-PEcBofi$m8L1_|EnnaW)5v55)X%bOdMv#^ZHN>Jhf9Jp$yifzB z<#GW#6pN;a;(W=dSTsdcESdtvqA3v<%^5+Ob-w9DEShsn8WqnH0>z>!P%N4P#iA)t zESdtvqB$chnxVw6T|N;T$8+%ps+8pls8S-Tl!&t^5mib=l@d{9fuJ%YsI+PjO(&=_ z$3Uf5kYN5vM3oXzr9@OI5mib=l^G%ba_NSUHlHCe2Cotj(gX@=0>yj@6w(9=X##~b zi70JGkY=54IuX+5nB9Ownm{2)z1T3WzQKdvwDG^mlM3oXzWkygr zDQU_!P-U(VfRR0!w^q`L8O2$Yh$MuSLJgl2dcCJ0gJ5kssYSOiKtQ{&Z0zADG^ml zM3n`C%8a1WI^T4HDsv1}`hclYBC3>#DkY*yiKtQ{s>}!~r$P=?DGND-%JYRvuNe?3 z1qzh{#ViUGDg_Fa0)h|(mYv;sj|Mv!JbY&t<{IVOz?XGMu9 zO(IH@h|(mYG>IrJBjlgYyM1r-rS$8ul;$E1%s-1>fNf)Kf` z!&YE)ttB@ALNOt?T?( zdD}GF=gi#l_RpuyndcrkZ<}UhE}#`#cI|RTZrKIDv@yNL-nqW-O=stqQ_Hc97yNPK zXH&6+$0xqOwUZO0FrlS#hB(33R)JMe)tb_l>+?3yq6@pFyEd=7p9F1{H7;2 zE--HYo$D`i#k3zl#LD9D!{Z<3f42LjJ&rH`(jHb8euu|FCj-jp!|ZE?UmFE+cNKX}WdU+$W13&G*T0yy>P4 z9}3D^nvC1uX6APQZ0YfS$~ypzvvcF(WAQ8ej`9M&qr|_czr{qK=Z`Y{< z93@|K@n7T`QD`Hb;O&HDow=4z@c4u6#+py?Q$>>b^d0B(PsI5=$~64~|6u1OYXg@6 z9~-IL^uNp}`0qaduWx_#(MP_Dx=qi%^kW}5d*a`I_CI~*hd=kl(+__2Gaown_`ARK z-UmMZ;*Y-LG@sv}e*stn&*?gowWU=e{DB`sP=%%ftuJE zp@^x8|H!@AUvRGVpr9{tO)J?=ZHdiWBYROD=eu zDhOGh6``FmLOUv^BbYT6LvN#`YK`7hYxI^0{NM%xyxobvs|#K`jvXz(*#<{gI%+~k zDyAkhp<+6MSyeG~G)k)0=t#9jN14D8YC`SL+OhtQ+`{bJitnpo^>ric%%@m*LxrH4 zgq?LvO~TGPrX!r`bxe30BUNkRZQWXUn+g1&8Wg=9|L@4H3$NsZ@71^Lssmr?ZcgX2 ztLrn-T7Dh{pZF426IxR-HK8>X(-9a@G4we~s@CXJwML(rz$fZJ!RMX$dwhkq`#k+u z=+HA?L*h%Ge}!&P2og~Z3PmESMVxRE75+w1*?JugS3_rta+s;Si3_2*79v6I?4B`pv!L~ zHHc1RRD-m^TCzc=Rz^rIufBw{!W{z8m1`lXHpoS4Plf6UCMA4gsu$F2knPuRh*$GCgO#qBasb=LL{paetZa0SwBB62P8}XaH8I z`4Q-@yIcjwK+zPJN6{3Q#~EN9^k?+TaUbQ+yXXIWZY!VvGtu(%f8?N--DwaLO-40{ zi6*03gp`p{%)xO~Hpm>54KfEa!so^&iaB_P0CLb}*ay~g@cGC=@rnk8S$`Ja=KSrA z22q%dY7m9Vs1{-BWfWbGqq0G|lnv5lMsVr%mT>yJLjW#ahP}7pE}x3`U2;t%nA-1v z7fUJuEXjxxz>th+02aFWgN6C>UUAJF14UC@GeuKeGiQLs(d#ZP$9Ojdt3tLt9xN; ze*X;;z4T6l!jy<=P?!=?E#jPusE{~{$_7KCXfPzs2ok;W5+&Xd0EsTcUXt+hn5=^o zrshjsNc5sR4GL2tszG5&M74->E}}x>C@LEaiK4-fI3q~(noE>;M*t+c47+f3iLwF` zP33)-kmxVKHi)KVRD)_~?L-ZVbQ4hxigXiEEn?z`sK~@oR5loyC>o4RoDn2? z)g@-)9RZN&GVDHqXQHfu6sG1AFeLikt_FoE5!IkDC8AoyITukOaTJvehD6a|NSqNQ z+C(uE^Dl565%?txw&vJSc?lDB9^S$2aK4+4rt)@PNW2Qsl#FT+P06ShVa{b#ET{8( z#=Fk9sBAEnQ_)~7r!#^po2Xb$?+AcIm(kWeqD1Tb;=;y@>@-d0*ElL0 zq%PSYb!7xyULOg%?ht^kRLsD3HQ05gJ!*(j1-3H#AMU?Bl`61nTwaMrb%S%SXJmKR zZhtb08ANfSshEK+8Wv(NQ=pi^_G(4HH5W5@8RR{WqWfqksRYlU&W<|9GORW^h(iGq7tjo9bM`2s=Ho zpPrw(F~ZNBis=Ys>ll2h#)#=GV))5ct#zd8f$j1vj$^Mw6@;mvda#a}s~YSVk^;GI zejc=Y;Y$;p+FG-_!hll}%=H+dITh29>NvKOq3A(P=v4K9=0-`? z13Fc$(T^f@s>nJ+d^--hSS5VFV8*K?w@jPF0@8}xkfB3q4EL!jS*T>F&%+F9fR2$ zBlM?Y=x>x%J)l3;8eJ$te~PSQU=9}SOzh?^CUdze0?Awz0WmCr$9f6-CGhZ>*e`cS z|F$oGvDTte0{xE>T2?U~fqxx?*&ieHuVUzblvF*Sf7KdYDnkE?tYhF37VP59{g*`t zDbVT*?gZj$-76;0;3%Rg8Py_KETS-pqlhA96itq!vKy2s8>AVDs8gbK6t3E>a)RGe zyk=hSw=$F{u=ixtYkZRX7`v3RjlG>CbYC{C`T z^Ko_^Mdh*L1S=Y(ouY^MIw8?I3Qlufg;-0MhxED%v6zk_y2)h*pv@a>5f@Z(ENUwn z#8gTYYpQ547S&Ny9xK*W(I5pBJ%l!i)=|hopF^vB={YECrc$O2eX*{3)rGh+8bdTD z8*C9|7EzeRQDpzVilR)}V2jFbP^N5*45{@ zuKHeoI?cZ!i+q|8=6MuRoQ!Hwc`PPq(IDnoB080b;>J;VEIO49(vC!QD$zO$GN;-M zEH^4^Glb0f$`vxbHUksX=5mp{SD`Q_qFO|mGK#Y}hC-%@3Ynv*>_*5G4TcthLZ(3L zD9H4gx9eVCZDo0A%Cx!w3u9g^fHFrBjmfAML1qz!SsX=_DWfQJ9F^UmOxYl{NJNnpG05Q#R1BAY~1i){ie!VFQGvpq zKw)nbmB$KuqQS5yP}mb_9R+*2iUZfe`ILvX(CZ~|EgS)`Vh>n$*}H410ay!V1Xi69 zz_n0Ba4j4I#lfLR_6pN10oOut@cjsA$<-W~O}R(`zK(k}2WHbKV#>&<79pF8D13}Z z5wl4~F`LFw*$rlsY>;^&5wl66briJast(j<2i_$Cw0R)`Y8yp#BcoabZABF3YZOtN zjH0%2RCdFfs(MHP@>tX+(K-s+@=q{PTmC5pX!Ftm)HaIfMn<&=+KMPl$|#~X8AWa5 zsO$!{$p$GvB5IRp9R+RqcdOUTalXb-XcJMPZ4{N=2yLRl z5Fk)!6KEX;ZTZK_s4f3k8M19E4}hs0MRX&hT2vm3SBgc0m`aJLO(JR=N9D1oO*TjY z5>cB(>nMEBo}}=mNmsLhPHnaqCqA#)z*HPX6epuv1gAw5CUX?gsf?o2aa4ALPGy6% zBN3fSw2p$)>1X};_(1-wUnJ=ADrPDoz@!;N);muc5!IsdSdpNzK_-nrk)Q%af{vo{ zSdpNj!ALcMB0&XOM?q#t@f%^XvO}CCmrZ(Qhd4=&AsVx{^%tq`4Ymj}izv+ED2kJ$ z=ti6*M^V|0I7x~IsYP}^PLi9VAk*j2UTAcg@@FwA)2a$A=H5#UQ06G2F&Wh&$Sk5T zi=&7#WfWzOqp};6DI26ri6~Q|brfW}6ss@cGG+aTIA<MGlIn$iY!mb|Z37G#J?=P~@OM>nLb@={n0)&D&sXB#Wif zYdnOuF%)h@REwxhMsdEzP-qiTp=}hE-3V=>!4M!&XcK511#P*i1GCNkyU;cj62MfB zBD#@LEh>){*P^0_4Wc&L4Qd-l&{jm1hyr93wMn#&g0?4FjKhmHSeW(3npkL?5TnLc;^&CRmT#W$dHY-zrPZppDUYBkr~GV`kyFy{3gD039i zn2c%>WEN4F#Zg3=GKwpW0fu2KMPUgv?@MiJe}s1`w6 z5rz30MbsvvsBIjT-JmwvAO%Q7Z4#}cpv~$%{Pl1DYZnh(;3SW2q1M7&?;#36O!XdY zkp3@jMZE{B^k9Fm^U%89Ln`m^hR43+=C|yB50wA;SGl~ye|P&oKKjUiM0tk~e&an~ zI{BAB{Hgcf_~ci9{+ZMF{D*h{!L^V7+GpQ;{&O$B=MOG&d52$r=d!#5Hl&{Vwtp0C zu&s_}`}XCv-4z(NBKL%@Rg9I-+iML!a(!m?a9ChreH6M>ZP`nhdu>I}?B3S_q#Ea8 z$JyRm%)tI=s=s3sD(3&Sw)q&P8qTIo$Y*;68)!}y5vU2xsTi8e1pPxLBbdv7hXq{e z(VQZjQAKEOlwht^%wfmb-dfB6%;n=(e^;d%=t311s7agx=WoK8bCh?WrQ90&az0tg zf1e|G_FK2b*;6sJG)ge9D(0}`Y;P@QfUhmi4>bv+>zFV)6ZHFHXLSC193?JV z)+A8Ahp(*OH9|L@}$m*08IlwN)VXIT92 z+QqHE`3gIyUwRFv|Bt!%i`6x$&O5WuKKtx__5u3PbmKEUGg6D2L`aretyqe3;R~+& z21`+-Tt;JzEv7GgGa6q3O`I_@wqX?0(1W2BD|m*)jQQIRq-`+4HUkF2#5NgQL(+zL z62rtIgA#cr%zD;S>#g;^-?M7(de7GoU-U8GZ?Af*)>F0W{Zq9HvZF7&wnf?3`pnpp z09ZdTO;dLi2@g2 zK*(J665tWnon*Vm{ma)oW2cR!YrwsCPRu~b^2RIp*z+h1P=ws|t3|hpnA;I5YKUse zD@D}pHcKVwVQCd{S6B@ZoG%O9d##BA7hf>^_NtcvYPw_XoLyJ**-$elYM@rO{eOw4 z@LZrl9fzPEpX?22|>X+_;KLY1sQ>Xrd?^D9v|iRep-sM|b(o2Av> zYfTim_yW4+N|XSCeTDvD9cCcJ2BBMZbqP$Xs zKVdaQWrO!#Yofr#7mSR(>LtJg7jpOnm(wZ0%5x@QaM_#0C0bq$AfdE?fEnN@SWf{^ z{ynxFiA%$s5nIpwD_iiv@L=18}CW*mI3*7PYh+y7WsIKSP(C4uP|fRc{$8h(f;eet;P7 zq!r^`M)0Q<$at5*awWz)iR?#<#}YB9%_D>-X|?xS69q25*tV(!5GqF@=JE>>XNQS{qY2)8vSn7MfwdshR7Lt(*i*a61BU4!AU|GZp*;ZOiLRo02& zP*@B=(Y!s^xMopHyVtEKeuh{%p#g_bo{Z6ZJ)yw>adb&5j;@TLfECEmmBDf)jxLGV z<0azgnnxH6(rWLuCJJ19fzg#KQ3Bllxg0jv`R(85ZrIym$S4vVgk=4;52Llvp z3ae1FBUIE13^hAgt`uqt6do2R)SN|hzOdSRt%(8`Ur^0ni4s6fm&0~rxSH=jZEEI( z2hd9H)h_{~Ei2@g2 z@TPG+1G|&90D|Qt2UNv8w%lpJjh^ISfGC)>qF@=Jl2#xE%V4>Z{b){ z+GDMfh_020GR`A-U0UtE)tu-N68bV8SW{>j-tR0zmv>zbK zD6J@CMyR0`NEtJLjD97`C=p#N5oMf5@Vd0xd##BA7hgceT!|83+sfx9IHSVrLG3^PfnD#nCU97;E_Bw!Iex;c91fmmV z{V3Qxf`6se-fK-1xcGw8UT<&-AlTCstZjP7!It2ws;~d#^Q7;NlC&m@82N$QVxY z+m>&Hj5&QlXw?@j!?~WmV1PnKVP$pm8g_&lT7e;B2g{X0MuEb$0)>pTh+Y>~d#^Q7 z;NlC)*eg*2zir_Tc+XBiRcu$_YTR9gD44XOU>TtKrT zYVWls3S4{v!Ez-^0Ksy~0t#kZ0at=~%7Ou+VA6_$WrVs|ffOtQ2c>u z1pi8_z1Ny3aPb8M%ater0;N02Hf?&K{OGMV-{lkrp_SN3KR_X)unHMFLJch^Lde(w zWb|YSA)`RyT7g2wSwyc3tG(BnC~)xwW$cwG0mfZUUO-j0Nhy7w`~XFu?B6foe7A}~ z*%9huC<0{%$p-JCWhLlDi6T&n0*46xl?Cp-){B2R`n;9pmoX zobNn00ktw0ufpe5h%!nm%9s&qXa!Qn3?QRliQ`Tpx>h1;HILwRX|?xS69q25fQ-2k zC4h|XBwHHcGQR!1h0mPEfHKO>^aDg0r4?n&2sO042v%vUdIpfu6DCkbiRfC1DC0bW z*QM3oYfTim_yRKKN|XTMbCtm0j%_KlruPH}k=vimQR=^zW;zf(Jnr}X*IS+ zo*!(D1bNb~@rrh)`IGG$Px|qlZKq+w4GY~(|md>pzqbOq3RLix!C^I zH>col+Ub&vD*1FtKwn;ujh#53IWMnA4f>*P{4ZNdjDKu_Dhz#XsP$DhyzUuKu(u_6 zu=j;sc4O)GVqq8l)D{NeA6t%se{7Kh3}YpO8|F{vfd19m29v}0#`it@Op5~SGavq@ zWzD9~MAmHl>DT$E{@uZrqk3^Ue=o{uZ=MhH!J>c}MvALCzGxU;{o6;nOvMHIB4 z7ZL2gx-x?OZjNC8{k@-l?9rco%u=v?;!l6?4^E%@*Pnma`FG#@g|D7&0$Pq>zw*O_ z2!{RMh+y_KYQf#&*m7so3GO=Qh;^32#{9N}Y-@n9f?7Y^LKnI?!r?Yc30sx3XP{Ng z6o)eB2wSCW*s58wtpQrqve=BEr{mzm%*K|@EH=#JG6=B&5>;lAR2FGUgDx%HqkD9a>s~&;9zEkORh>2 zarVfm-2hxOTLfKZ)9-5ZB9DW8X0{$NN9ZzTLzm5xZ4J=;H+Y|Z(pN6{Z2+5)_wVCQ zyCIY?i`WWj#b(T-Z8b1!h?D_rifu<<)Id{wk2>X;MQn<+VpHZ3nv$(BJ9-UcmATwS z&z4!*gjLJ@-i$4qMQoY0V$0?cT9&QQvUc~#o0NR_2x0v0kuA&5s0pFSS;Uq}E4FMN zZL5KnwOe1_){0wSw8C$F*}(0G!-i1uEMfzt6&pB@w$(rbTMZBI+tD(=Z;zJs;Fl1K zoI}wvVHGW#McZm1a(wZ`w?(xR&vCf(`g>{v#ok(sH)(;|7XVA;#Wm2V0<5orTKA_R zJpH%hy^E8nS;rnMk_8GDfR&d3b({n1YoIfEbuH}qbHumeK{4+RWoNRNDysvM9rSnQPjFqcU3P$gTT4PuvB zG^NXjrnF;pS;icSrU{|Va06nOkGz)cS0K1m3VAq-h z>xsv~xW15+-)=Og2DB2*bq7Ac%ZRl2L zLtePXHpsJIqKB>B=8LEXD0-N%iXJwL*u%ONdRSY_#n*mvEmx?sj;{WNm({oLyeoCC zLZObZ3Uy`?)#+AHCog1T8}gDG^ssgw3yfzG+aRsjhIzED2HLRtRF1Pq?Db)M_qN9# z`@x=b;>)-N0sFz!h;Qc?zP86iy7zH@Dz^t~FXN^CQ+a$lPoK*F%PT*Xe=dJ2|L>Mh z^@4EBcpZxCoZoK0UfBM}w-t@6gzU|`UFTC}Ack-w5cRm%Tm8@6$ z;3MCuRq?^Qr~Z)-kEQsE)lV{^jp0c5Oickqf=vPH*8>**mwk?HvIOhfGUC#pQab+Xb>nj^$ z5u-T!8}X%(TK%BCh%TG>!(MW~u0)IgEi#{Rl!sU;!X z5GalFU!b(U%GLt&V4Qyf)$f`f4;M>L@wZ%F`rM8)Q6f(F=Rk~Fta6DsJx9G5PCaox$MAS?ox>KTo6=f@|w%)*pDqAcyywcVNck*KSQcS4*V>=#7d~p!RS^f@x zJ%lS4Rwbf9(rSQWUKN#(iB%w~P+W_X-kcS+lLd0jWd!Zaz0@v)#kq$z&@P{ELeb>; zArx&ML7Bp;L{vsv4G=|>RuoMliY5_VDbc`+q7_!#CPOqo?FHTAnl^Z`(Uzj|rJ!7j z7UQqKHb6h{g5{0S7JLZE?Fj6Z3$Z8D@Z_YSYL zwZZu78%!Wj+hBrQt(?ItAxv)b2v;twN<@LA)c{dDX+`ZMq6!kxqY@3Qs9j;TZ8Eeo z&kwJ(wSji|1QZT98(YhB1w@NILFWE>gi97yC89FYYJe!3w4!Jd(ftz9l@blCC|Y5) zZ8AhN_YSYLwSj1P#Uw@Hn@XyNo?7iuOs`5Sj3zc0LO&ZUvEN6 zU*$_FYQ{H9#?;iOR=>CJ;p_t_256G_Z;ZOLG7d!Ju1<_ zirN)c+iE~NbMNp>>!8w4jEiDQFJ)}hm7zM>OAd)N!&Xxdf zlc2O<6ciWdEq@2A15oG5$p5VT0Rg6DrwQVw_H1`g#w6%ehEm03Y z2INFNxRdlfGchXHPlpZhYw0XPMe^>KbgdG_&=c1hpcs|HDn_M1F}?(fu_DmGDn_NW z+BO+dntO*=+S))$m&7ug`%}3*Q-xB_@ATnkgY{hMdy(ux)1)j4ED`mRRs%#Sr4^-= zh@wbD2TL@tqLhWzw#ksv95uYs)&^2G=a#9lFkpNKRVccCifD+k%_7!ET9qhd6IKHh ziVCYxRG`pCpzyOm1FKL}T5X#QMa{j#D{XC{Xxs0`TUL4E3Z=CFu5MOZ<}*k+k5G}q zszlUFS`84Tlvb2dB8nmr9W2qnic%I<+a^OwbMNp+yPUb85sOi}rmG6kY2#kKHWMxudLOqudp+a^Owb1$VVuC%p*lrD)a zo$x91={Qgz?>Mx+WR=hR`QTpPamX9ld4!4-Rwbg8(rSPxrL>}y5>XV1=wOKkR+O@^ z+BO+dntO*=+S=ejGHK}g)+iwfI>836`~0g?iVOrDbT!Z{ zLNsZ$Z8AhN_YSYLwL#EyNvy#hH05?#N@?%y*0(Z!Kg&EqMGC7DQ7>sVK$KEiQA&v@ zibQmm@Jd@7h?Y-diIKK`Hup`=44QKP^AO>Zg;j}SIEiWu zP>eKT6(dcc7&8Jz{}*Ur6(dbrZJP|y%)P@aZEYZ0-qjtV^*z(LJ@KvEq0B7eC8bq~ zLK$H-Kp~p23ef}#_X`xR6lh=-qDiZ5lOdY9cX*|(4MfY^*eTjJqh#A8&d2lh+r6t1 zE?E>KWboaTDKi6p zII&a>zqU=)V3`?y;_=sCzRr$edF0B>4F6lt%L7an``7jTNfJAUUae{|!` zzx}?qoqzl-Z~N-$yTA07-#>ZN$Nu5ZJn$=T`qtB{)C?bc%T=ivZf;UDU@IdMFjzv0 zM=S-va$_C8qsPqrZr+lAkM5<)seiZSAEG|!i|TiDDfK&gq56IP?`?Iss1N$9{$01% z)(5ZtK9VoIa+7=kn}p9ymxnCi;M^I!_`@dBGe6(}kEu{hAI`v0t`1tL33St?F)*mo zMg+r7+f;->LD?{pD6-E6uS<1lHK}|JJ=di{rR`x9)GDV&jZ_L$WR6fPWkW?~2^7&b z6`|ji4Yg8apA8hzrPTylDI02)3AEy|8@APcbc___!Zbi3ZO|`)G6;p7BNS5EP{>&V zxwK71D2lS7kc#ZHfn2(@nm|ZpLm@LkxP(6TVa3B`KVSqB-|^6Sc$mnZ{0i&=IU7La zS6~mABkTdnhCN`GKxb`J5vr+d*aH;VX9JyeX*EF)P&VuVnLy`*^HM05%nm@cW)bz0 zRupU=L72j-L=-?;Q8kJ7T0x$ozyX4E(u&$;gnM$4VG&Y_q%g;mU) zvj`I9MaOs;^cwP^Nh0bXQB0qr8hfoEk-V10&l+(K71fAIv?HjOvn)`yv(2X!-_p-P zH;X8nw4!YD2(lGcC87Y*in2+x*9zJc1r88olU9^1BfQgSc^3GcOy*gLF}QxU(hk(& z7@R{f28C6O!CAyHD6L8qqe)oB7!+u)6$hFuaDX@lg;k8fjv%IsV(#@9RQZ%6z~@iw z?&t^FsSeM_e#qxf{6(Z^WuVeBqd?r*^y?waxzzwsX;~nZo=1?guqqKXlU7t(qPG>KU+8lsj(gJeL0&K++wepv73c%sg z!hY0Xk42fUp#(+CAxy3=wr_cc2QHxe;|P}`ivtH*PBVak<_R7+&}I<_nzZ6Tn@1Q; zg;j|-WTX`|EDGFfh0!Fh#e38OVW3IGftC@XW;@r6^P@c1EW})YX7^MAA?6$kF@;r# zIg6;6v?@`^BCJA8f%aNaOj+OnQA}YKVs?b6nKL=WWVc?+<(V8}vYSIO*$Jzd>}C

MX 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ʆYFXyz)SV^Y;1nweD?gFkcA-?H33+zH$4J?@=tj^aEhP@D$^iu0gEuuaxQ z+a!wfpeQiTgA(yPC{UaS1&Z^aK(wuBVc9QwBUKHG^Wba-Y|9yBINEY@6rAxqgA8qx zh_*>Y+X{s4NkrQuqHPkN{ zF+)!+IJ=?fsZHpqMD$c5dRicODiJ-Eh@MJhKh_1pQ>mh-646tM=;MQ6B3@UV_ymd*pFq~JxFG80!Z`%6+!ZW3k+6lMw(W=h0R2^3}u6lMw( zW=cde1qw3-imMob!m%|JjtNzR!py0HnOnvgZUQepVvUiRld0e&=^1BerbIMTBAQts zm?;s>l!#_ZL^BHnGbNʆ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 KJKy-aul@h5vgQK- diff --git a/tests/test_adaptive_siege.py b/tests/test_adaptive_siege.py new file mode 100644 index 0000000..c777319 --- /dev/null +++ b/tests/test_adaptive_siege.py @@ -0,0 +1,264 @@ +"""Deterministic tests for the replay-driven adaptive siege stage.""" + +from pathlib import Path +import importlib.util +import sys + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_bot(): + bot_dir = ROOT / "bots" / "exp_adaptive_siege" + sys.path.insert(0, str(bot_dir)) + try: + spec = importlib.util.spec_from_file_location( + "exp_adaptive_siege_main", bot_dir / "main.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +class DummyPathfinder: + def __init__(self): + self.moves = [] + + def moveTo(self, ct, target): + self.moves.append(target) + + +class FakeController: + def __init__(self, position, nearby, fire_plans=None, unsafe=None): + self.position = position + self.nearby = list(nearby) + self.fire_plans = set(fire_plans or ()) + self.unsafe = dict(unsafe or {}) + self.buildings = {} + self.teams = {} + self.types = {} + self.hp = {} + self.max_hp = {} + self.built = [] + self.healed = [] + self.writes = [] + self.round = 100 + self.store = { + BOT.SLOT_SIEGER_1_ID: 8, + BOT.SLOT_SIEGER_2_ID: 10, + } + self.entity_id = 7 + + def get_position(self, entity_id=None): + return self.position + + def get_nearby_tiles(self): + return self.nearby + + def get_tile_env(self, position): + return BOT.Environment.EMPTY + + def get_tile_building_id(self, position): + return self.buildings.get(position) + + def get_tile_builder_bot_id(self, position): + return None + + def is_in_vision(self, position): + return True + + def is_tile_passable(self, position): + return True + + def can_fire_from(self, position, facing, turret_type, target): + return (position, facing, turret_type, target) in self.fire_plans + + def get_attackable_tiles_from(self, position, facing, turret_type): + return self.unsafe.get((position, facing, turret_type), []) + + def get_team(self, entity_id=None): + if entity_id is None: + return 1 + return self.teams[entity_id] + + def get_entity_type(self, entity_id=None): + return self.types[entity_id] + + def get_hp(self, entity_id=None): + return self.hp[entity_id] + + def get_max_hp(self, entity_id=None): + return self.max_hp[entity_id] + + def can_heal(self, position): + return True + + def heal(self, position): + self.healed.append(position) + + def can_act(self): + return True + + def get_global_resources(self): + return 500 + + def can_build_gunner(self, position, facing): + return self.get_tile_building_id(position) is None + + def build_gunner(self, position, facing): + self.built.append((position, facing)) + building_id = 99 + self.buildings[position] = building_id + self.teams[building_id] = 1 + self.types[building_id] = BOT.EntityType.GUNNER + self.hp[building_id] = 40 + self.max_hp[building_id] = 40 + return building_id + + def get_current_round(self): + return self.round + + def get_id(self): + return self.entity_id + + def read_store(self, slot): + return self.store.get(slot, 0) + + def write_store(self, slot, value): + self.writes.append((slot, value)) + self.store[slot] = value + + +def fresh_player(): + player = BOT.Player() + player.mapW = 20 + player.mapH = 20 + player.mapPf = DummyPathfinder() + return player + + +def test_undefended_core_selects_direct_firing_seat(): + target = BOT.Position(10, 10) + seat = BOT.Position(8, 8) + stand = seat.add(BOT.Direction.NORTH) + facing = BOT.Direction.SOUTHEAST + ct = FakeController( + stand, + [seat, stand], + {(seat, facing, BOT.EntityType.GUNNER, target)}, + ) + player = fresh_player() + player.enemyCorePos = target + + player._run_direct_core_siege(ct, stand, target) + + assert ct.built == [(seat, facing)] + + +def test_one_and_three_defenders_split_sieger_roles(): + assert not BOT.siege_role_counters(1, 0) + assert not BOT.siege_role_counters(2, 0) + assert not BOT.siege_role_counters(1, 1) + assert BOT.siege_role_counters(2, 1) + assert BOT.siege_role_counters(1, 3) + assert BOT.siege_role_counters(2, 3) + + +def test_counter_plan_rejects_unsafe_seat_and_builder_stand(): + target = BOT.Position(10, 10) + defender = BOT.Position(9, 10) + defender_facing = BOT.Direction.WEST + unsafe_seat = BOT.Position(7, 10) + unsafe_stand = unsafe_seat.add(BOT.Direction.NORTH) + safe_seat = BOT.Position(8, 8) + safe_stand = safe_seat.add(BOT.Direction.NORTH) + firing = BOT.Direction.SOUTHEAST + ct = FakeController( + safe_stand, + [unsafe_seat, unsafe_stand, safe_seat, safe_stand], + { + (unsafe_seat, firing, BOT.EntityType.GUNNER, target), + (safe_seat, firing, BOT.EntityType.GUNNER, target), + }, + { + (defender, defender_facing, BOT.EntityType.GUNNER): + [unsafe_seat, unsafe_stand], + }, + ) + defenders = [( + defender.x, + defender.y, + 40, + defender, + BOT.EntityType.GUNNER, + defender_facing, + )] + player = fresh_player() + + plan = player._counter_plan(ct, safe_stand, target, defenders) + + assert plan == (safe_seat, safe_stand, firing) + + +def test_counter_gunner_is_maintained_and_healed_before_retargeting(): + target = BOT.Position(10, 10) + seat = BOT.Position(8, 8) + stand = seat.add(BOT.Direction.NORTH) + facing = BOT.Direction.SOUTHEAST + ct = FakeController(stand, [seat, stand]) + ct.buildings[seat] = 55 + ct.teams[55] = 1 + ct.types[55] = BOT.EntityType.GUNNER + ct.hp[55] = 24 + ct.max_hp[55] = 40 + selected = ( + target.x, + target.y, + 70, + target, + BOT.EntityType.GUNNER, + BOT.Direction.WEST, + ) + player = fresh_player() + player._counterTarget = target + player._counterSeat = seat + player._counterStand = stand + player._counterFacing = facing + + player._run_counter_battery(ct, stand, selected, [selected]) + + assert ct.healed == [seat] + assert ct.built == [] + assert player.mapPf.moves == [] + + +def test_stall_pauses_for_32_rounds_then_allows_retry(): + objective = BOT.Position(10, 10) + ct = FakeController(BOT.Position(5, 5), []) + player = fresh_player() + + assert player._update_siege_progress(ct, objective, 3, 500) + for _ in range(BOT.SIEGE_STALL_ROUNDS - 1): + ct.round += 1 + assert player._update_siege_progress(ct, objective, 3, 500) + ct.round += 1 + assert not player._update_siege_progress(ct, objective, 3, 500) + pause_started = ct.round + + assert player._siegePauseUntil == pause_started + 32 + assert pause_started + 31 < player._siegePauseUntil + assert pause_started + 32 >= player._siegePauseUntil + assert (BOT.SLOT_SIEGER_1_TARGET, 0) in ct.writes + + +if __name__ == "__main__": + test_undefended_core_selects_direct_firing_seat() + test_one_and_three_defenders_split_sieger_roles() + test_counter_plan_rejects_unsafe_seat_and_builder_stand() + test_counter_gunner_is_maintained_and_healed_before_retargeting() + test_stall_pauses_for_32_rounds_then_allows_retry() + print("adaptive siege tests passed") diff --git a/tests/test_connected_economy.py b/tests/test_connected_economy.py new file mode 100644 index 0000000..a79923e --- /dev/null +++ b/tests/test_connected_economy.py @@ -0,0 +1,247 @@ +"""Deterministic mechanism tests for exp_connected_economy.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_connected_economy" + + +def load_bot(): + sys.path.insert(0, str(BOT_DIR)) + try: + spec = importlib.util.spec_from_file_location( + "connected_economy_main", BOT_DIR / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +def footprint(): + return BOT.core_footprint_positions(Position(0, 0)) + + +def test_only_actual_facing_chains_are_connected(): + conveyors = { + Position(2, 0): Direction.WEST, + Position(3, 0): Direction.WEST, + # Spatially touches the valid trunk, but points away from it. + Position(2, 1): Direction.SOUTH, + } + connected = BOT.proven_connected_conveyors(conveyors, footprint()) + assert connected == {Position(2, 0), Position(3, 0)} + + +def test_wrong_facing_harvester_neighbor_is_not_a_root(): + harvester = Position(5, 5) + conveyors = { + Position(4, 5): Direction.EAST, # outputs into the harvester + Position(5, 4): Direction.WEST, # accepts from the harvester + } + assert BOT.harvester_chain_roots(harvester, conveyors) == [ + Position(5, 4) + ] + + +def test_dead_end_is_extended_but_cycle_is_rejected(): + dead_end = { + Position(5, 5): Direction.WEST, + Position(4, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), dead_end, footprint(), set() + ) == ("dead_end", Position(3, 5)) + + cycle = { + Position(5, 5): Direction.EAST, + Position(6, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), cycle, footprint(), set() + ) == ("cycle", None) + + +class ObservationController: + def __init__(self): + self.entities = {} + + def get_tile_building_id(self, position): + record = self.entities.get(position) + return None if record is None else record[0] + + def get_team(self, entity_id=None): + if entity_id is None: + return 1 + for record in self.entities.values(): + if record[0] == entity_id: + return record[1] + raise KeyError(entity_id) + + def get_entity_type(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[2] + raise KeyError(entity_id) + + def get_direction(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[3] + raise KeyError(entity_id) + + +def test_revisited_tiles_clear_stale_economy_memory(): + pathfinder = BOT.MapPathfinder() + controller = ObservationController() + conveyor = Position(3, 3) + harvester = Position(4, 4) + controller.entities[conveyor] = ( + 10, 1, EntityType.CONVEYOR, Direction.WEST) + controller.entities[harvester] = ( + 11, 1, EntityType.HARVESTER, None) + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert pathfinder.knownTeamConveyors[conveyor] == Direction.WEST + assert harvester in pathfinder.knownTeamHarvesters + + controller.entities.clear() + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert conveyor not in pathfinder.knownTeamConveyors + assert harvester not in pathfinder.knownTeamHarvesters + + +class CandidateController(ObservationController): + def __init__(self): + super().__init__() + self.builders = {} + + def is_in_vision(self, position): + return True + + def is_tile_passable(self, position): + return position not in self.builders + + def get_tile_builder_bot_id(self, position): + return self.builders.get(position) + + def get_team(self, entity_id=None): + if entity_id in self.builders.values(): + return 1 + return super().get_team(entity_id) + + +def test_served_harvester_produces_no_route_work(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(4, 0) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.WEST, + } + controller = CandidateController() + controller.entities = { + harvester: (20, 1, EntityType.HARVESTER, None), + Position(3, 0): (21, 1, EntityType.CONVEYOR, Direction.WEST), + Position(2, 0): (22, 1, EntityType.CONVEYOR, Direction.WEST), + } + assert pathfinder.economyRouteCandidates(controller) == [] + + +def test_wrong_facing_neighbor_does_not_hide_orphan_harvester(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + wrong = Position(4, 5) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = {wrong: Direction.EAST} + controller = CandidateController() + controller.entities = { + harvester: (30, 1, EntityType.HARVESTER, None), + wrong: (31, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.economyRouteCandidates(controller) + ends = {candidate[1] for candidate in candidates} + assert wrong not in ends + assert ends == { + Position(5, 4), Position(5, 6), Position(6, 5) + } + + +def test_friendly_builder_on_missing_link_keeps_route_candidate_alive(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + occupied_end = Position(5, 4) + pathfinder.knownTeamHarvesters = {harvester} + controller = CandidateController() + controller.entities = { + harvester: (40, 1, EntityType.HARVESTER, None), + } + controller.builders[occupied_end] = 41 + candidates = pathfinder.economyRouteCandidates(controller) + assert occupied_end in {candidate[1] for candidate in candidates} + + +def test_late_repair_is_bounded_and_below_direct_threat_priority(): + allowed = BOT.late_route_repair_allowed + assert not allowed(79, 500, 1) + assert allowed(80, BOT.LATE_ROUTE_RESOURCE_FLOOR, + BOT.LATE_ROUTE_MAX_LINKS) + assert not allowed(80, BOT.LATE_ROUTE_RESOURCE_FLOOR - 1, 1) + assert not allowed(80, 500, BOT.LATE_ROUTE_MAX_LINKS + 1) + assert BOT.LATE_ROUTE_SCORE_CAP < 10 + + +def test_one_nearest_builder_claims_a_late_repair(): + target = Position(5, 5) + candidates = [ + (9, Position(3, 5)), + (7, Position(5, 3)), + (4, Position(9, 9)), + ] + assert BOT.choose_route_repairer(candidates, target) == 7 + assert BOT.choose_route_repairer(candidates, target, {7, 9}) == 4 + assert BOT.choose_route_repairer([], target) is None + + +def test_experiment_preserves_protocol_and_has_no_fingerprints(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + source = "\n".join( + (BOT_DIR / name).read_text(encoding="utf-8").lower() + for name in ("main.py", "mapPathfinding.py") + ) + for fingerprint in ("string.map", "bridge.map", "duel.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_only_actual_facing_chains_are_connected() + test_wrong_facing_harvester_neighbor_is_not_a_root() + test_dead_end_is_extended_but_cycle_is_rejected() + test_revisited_tiles_clear_stale_economy_memory() + test_served_harvester_produces_no_route_work() + test_wrong_facing_neighbor_does_not_hide_orphan_harvester() + test_friendly_builder_on_missing_link_keeps_route_candidate_alive() + test_late_repair_is_bounded_and_below_direct_threat_priority() + test_one_nearest_builder_claims_a_late_repair() + test_experiment_preserves_protocol_and_has_no_fingerprints() + print("connected economy tests passed") diff --git a/tests/test_core_strategy.py b/tests/test_core_strategy.py new file mode 100644 index 0000000..ea2d7dc --- /dev/null +++ b/tests/test_core_strategy.py @@ -0,0 +1,180 @@ +"""Deterministic protocol tests for the core-race experiments.""" + +from pathlib import Path +import importlib.util +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] + + +class Pos: + def __init__(self, x, y): + self.x = x + self.y = y + + +def read_varint(buf, index): + value = 0 + shift = 0 + while True: + byte = buf[index] + index += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, index + shift += 7 + + +def parse_map(path): + buf = path.read_bytes() + index = 0 + width = height = None + rows = [] + cores = [] + while index < len(buf): + tag, index = read_varint(buf, index) + field, wire = tag >> 3, tag & 7 + if wire == 0: + value, index = read_varint(buf, index) + if field == 1: + width = value + elif field == 2: + height = value + elif wire == 2: + length, index = read_varint(buf, index) + payload = buf[index:index + length] + index += length + if field == 3: + inner = 0 + _, inner = read_varint(payload, inner) + row_length, inner = read_varint(payload, inner) + rows.append(payload[inner:inner + row_length]) + elif field == 4: + cores.append(parse_core(payload)) + else: + raise AssertionError(f"unsupported map wire type {wire}") + return width, height, rows, cores + + +def parse_core(payload): + index = 0 + team = x = y = 0 + while index < len(payload): + tag, index = read_varint(payload, index) + field, wire = tag >> 3, tag & 7 + if wire == 0: + value, index = read_varint(payload, index) + if field == 1: + team = value + elif wire == 2: + length, index = read_varint(payload, index) + nested = payload[index:index + length] + index += length + inner = 0 + while inner < len(nested): + nested_tag, inner = read_varint(nested, inner) + nested_field, nested_wire = nested_tag >> 3, nested_tag & 7 + if nested_wire != 0: + break + value, inner = read_varint(nested, inner) + if nested_field == 1: + x = value + elif nested_field == 2: + y = value + else: + raise AssertionError(f"unsupported core wire type {wire}") + return team, x, y + + +def load_symmetry_module(bot_name): + path = ROOT / "bots" / bot_name / "symmetry.py" + spec = importlib.util.spec_from_file_location(f"{bot_name}_symmetry", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_all_map_core_predictions(bot_name="exp_symmetry"): + symmetry = load_symmetry_module(bot_name) + checked = 0 + for path in sorted((ROOT / "maps").glob("*.map26")): + width, height, rows, cores = parse_map(path) + # map bytes: 0 empty, 1 wall, 2 ore. Bot map: 0 empty, 1 ore, 2 wall. + translate = {0: 0, 1: 2, 2: 1} + full_map = [ + [translate[rows[y][x]] for y in range(height)] + for x in range(width) + ] + assert len(cores) == 2, path.name + for source_index, target_index in ((0, 1), (1, 0)): + _, source_x, source_y = cores[source_index] + _, target_x, target_y = cores[target_index] + tracker = symmetry.SymmetryTracker(width, height) + tracker.update(full_map, 1, Pos(source_x, source_y)) + predicted = tracker.enemy_core(Pos(source_x, source_y)) + assert predicted == (target_x, target_y), ( + path.name, (source_x, source_y), predicted, (target_x, target_y), + tracker.alive) + checked += 1 + assert checked == 42 + + +def test_stable_role_protocol(bot_name="exp_roles"): + source = (ROOT / "bots" / bot_name / "main.py").read_text(encoding="utf-8") + assert "SLOT_WALLER_ID = 8" in source + assert "SLOT_SIEGER_1_ID = 9" in source + assert "SLOT_SIEGER_2_ID = 10" in source + assert "spawned_id + 1" in source + assert "get_id() > 4" not in source + assert "mySpawnIdx" not in source + + +def test_core_sniper_protocol(bot_name="exp_core_sniper"): + source = (ROOT / "bots" / bot_name / "main.py").read_text(encoding="utf-8") + assert "SIEGE_START = 45" in source + assert "SIEGE_TITANIUM_FLOOR = 120" in source + assert "ct.can_fire_from(" in source + assert "ct.can_build_gunner(seat, facing)" in source + assert "curScore += 1000" in source + test_stable_role_protocol(bot_name) + + +def test_local_promotion_package(bot_name="core-sniper-v1"): + required = { + "main.py", + "initialSpawning.py", + "mapPathfinding.py", + "symmetry.py", + } + bot_dir = ROOT / "bots" / bot_name + for name in required: + assert (ROOT / name).read_bytes() == (bot_dir / name).read_bytes(), name + with zipfile.ZipFile(ROOT / "bot.zip") as archive: + assert set(archive.namelist()) == required + for name in required: + assert archive.read(name) == (bot_dir / name).read_bytes(), name + + +def test_generalist_v2_protocol(): + test_all_map_core_predictions("generalist-v2") + test_core_sniper_protocol("generalist-v2") + + +def test_generalist_v3_protocol(): + test_all_map_core_predictions("generalist-v3") + test_core_sniper_protocol("generalist-v3") + + +if __name__ == "__main__": + test_all_map_core_predictions() + test_all_map_core_predictions("exp_roles") + test_all_map_core_predictions("exp_core_sniper") + test_all_map_core_predictions("core-sniper-v1") + test_stable_role_protocol() + test_core_sniper_protocol() + test_core_sniper_protocol("core-sniper-v1") + test_generalist_v2_protocol() + test_generalist_v3_protocol() + test_local_promotion_package() + print("core strategy protocol tests passed") diff --git a/tests/test_generalist_cycle.py b/tests/test_generalist_cycle.py new file mode 100644 index 0000000..6d6454d --- /dev/null +++ b/tests/test_generalist_cycle.py @@ -0,0 +1,364 @@ +"""Deterministic tests for the isolated generalist experiments.""" + +from pathlib import Path +import hashlib +import importlib.util +import json +import sys +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_bot(bot_name): + bot_dir = ROOT / "bots" / bot_name + sys.path.insert(0, str(bot_dir)) + try: + spec = importlib.util.spec_from_file_location( + f"{bot_name}_main", bot_dir / "main.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +SPAWN = load_bot("exp_spawn_discipline") +RESPONDER = load_bot("exp_local_responder") +SURGICAL = load_bot("exp_surgical_counter") +STACK = load_bot("exp_generalist_stack") + + +class FakeNearbyUnits: + def __init__(self, units): + self.units = units + + def get_team(self, unit_id=None): + if unit_id is None: + return 1 + return self.units[unit_id][0] + + def get_entity_type(self, unit_id): + return self.units[unit_id][1] + + def get_nearby_units(self): + return list(self.units) + + +def test_extra_spawning_stops_at_six_and_resumes_after_dispersal(): + assert SPAWN.extra_spawn_allowed(5) + assert not SPAWN.extra_spawn_allowed(6) + assert not SPAWN.extra_spawn_allowed(9) + assert SPAWN.extra_spawn_allowed(4) + + +def test_spawn_congestion_counts_only_nearby_friendly_builders(): + units = { + 1: (1, SPAWN.EntityType.CORE), + 2: (1, SPAWN.EntityType.BUILDER_BOT), + 3: (1, SPAWN.EntityType.BUILDER_BOT), + 4: (2, SPAWN.EntityType.BUILDER_BOT), + 5: (1, SPAWN.EntityType.GUNNER), + } + assert SPAWN.count_nearby_friendly_builders( + FakeNearbyUnits(units)) == 2 + + +def test_first_five_opening_block_precedes_congestion_rule(): + source = ( + ROOT / "bots" / "exp_spawn_discipline" / "main.py" + ).read_text(encoding="utf-8") + opening = source.index("if self.fiveDirections and len(self.fiveDirections) > 0") + congestion = source.index( + "nearby_friendly_builders = count_nearby_friendly_builders(ct)") + assert opening < congestion + assert "SLOT_WALLER_ID, spawned_id + 1" in source + assert "SLOT_SIEGER_1_ID, spawned_id + 1" in source + assert "SLOT_SIEGER_2_ID, spawned_id + 1" in source + + +class FakeThreatController: + def __init__(self): + self.core = RESPONDER.Position(5, 5) + self.entities = { + 10: ( + 2, + RESPONDER.EntityType.GUNNER, + RESPONDER.Position(8, 5), + RESPONDER.Direction.WEST, + ), + 11: ( + 2, + RESPONDER.EntityType.SENTINEL, + RESPONDER.Position(9, 9), + RESPONDER.Direction.NORTH, + ), + 12: ( + 2, + RESPONDER.EntityType.BUILDER_BOT, + RESPONDER.Position(4, 5), + None, + ), + } + + def get_position(self, entity_id=None): + return self.core if entity_id is None else self.entities[entity_id][2] + + def get_team(self, entity_id=None): + return 1 if entity_id is None else self.entities[entity_id][0] + + def get_entity_type(self, entity_id): + return self.entities[entity_id][1] + + def get_direction(self, entity_id): + return self.entities[entity_id][3] + + def get_nearby_buildings(self): + return [10, 11] + + def get_nearby_units(self): + return [10, 11, 12] + + def get_attackable_tiles_from(self, position, facing, entity_type): + if position == self.entities[10][2]: + return [RESPONDER.Position(6, 5)] + return [RESPONDER.Position(9, 8)] + + +def test_home_threat_detection_is_exact(): + player = RESPONDER.Player() + threats = player._home_threats(FakeThreatController()) + assert [threat[3] for threat in threats] == [10, 12] + + +def test_home_defender_selection_prefers_one_non_sieger_then_distance_and_id(): + threat = RESPONDER.Position(5, 5) + candidates = [ + (20, RESPONDER.Position(5, 4)), + (21, RESPONDER.Position(6, 5)), + (22, RESPONDER.Position(8, 8)), + ] + assert RESPONDER.choose_home_defender( + candidates, {20}, threat) == 21 + assert RESPONDER.choose_home_defender( + candidates, {20, 21}, threat) == 22 + tied = [ + (31, RESPONDER.Position(5, 4)), + (30, RESPONDER.Position(4, 5)), + ] + assert RESPONDER.choose_home_defender(tied, set(), threat) == 30 + + +class FakeAssignmentController: + def __init__(self): + self.round = 20 + self.units = { + 20: RESPONDER.Position(4, 5), + 21: RESPONDER.Position(7, 5), + 22: RESPONDER.Position(8, 5), + } + self.store = { + RESPONDER.SLOT_SIEGER_1_ID: 21, + RESPONDER.SLOT_SIEGER_2_ID: 0, + } + + def get_current_round(self): + return self.round + + def get_team(self, entity_id=None): + return 1 + + def get_nearby_units(self): + return list(self.units) + + def get_entity_type(self, entity_id): + return RESPONDER.EntityType.BUILDER_BOT + + def get_position(self, entity_id): + return self.units[entity_id] + + def read_store(self, slot): + return self.store.get(slot, 0) + + def write_store(self, slot, value): + self.store[slot] = value + + +def test_home_assignment_holds_cleans_up_and_reassigns_dead_builder(): + threat = RESPONDER.Position(6, 5) + threat_record = (0, threat.x, threat.y, 99, threat) + player = RESPONDER.Player() + ct = FakeAssignmentController() + player._home_threats = lambda controller: [threat_record] + + player._assign_home_defender(ct) + assert ct.store[RESPONDER.SLOT_HOME_DEFENDER_ID] == 22 + + del ct.units[21] + ct.round += 1 + player._assign_home_defender(ct) + assert ct.store[RESPONDER.SLOT_HOME_DEFENDER_ID] == 23 + + player._home_threats = lambda controller: [] + ct.round += RESPONDER.HOME_DEFENDER_HOLD_ROUNDS + player._assign_home_defender(ct) + assert ct.store[RESPONDER.SLOT_HOME_DEFENDER_ID] == 23 + + ct.round += 1 + player._assign_home_defender(ct) + assert ct.store[RESPONDER.SLOT_HOME_DEFENDER_ID] == 0 + assert ct.store[RESPONDER.SLOT_HOME_THREAT] == 0 + + +class FakeSurgicalController: + def __init__(self, position): + self.position = position + self.store = { + SURGICAL.SLOT_SIEGER_1_ID: 8, + SURGICAL.SLOT_SIEGER_2_ID: 10, + } + self.writes = [] + self.built = [] + + def get_id(self): + return 9 + + def read_store(self, slot): + return self.store.get(slot, 0) + + def write_store(self, slot, value): + self.store[slot] = value + self.writes.append((slot, value)) + + def get_attackable_tiles_from(self, position, facing, entity_type): + return [SURGICAL.Position(7, 7), SURGICAL.Position(7, 6)] + + def can_act(self): + return True + + def get_global_resources(self): + return 500 + + def can_build_gunner(self, position, facing): + return True + + def build_gunner(self, position, facing): + self.built.append((position, facing)) + + +def test_surgical_counter_uses_safe_direct_plan_and_cleans_target_slot(): + target = SURGICAL.Position(10, 10) + seat = SURGICAL.Position(8, 8) + stand = SURGICAL.Position(8, 7) + facing = SURGICAL.Direction.SOUTHEAST + plan = ((0, 0, 0, 0, 0), seat, stand, facing) + player = SURGICAL.Player() + player._enemy_core_target = lambda: target + player._direct_siege_plans = lambda ct, loc, tgt: [plan] + player._visible_defenders = lambda ct: [] + ct = FakeSurgicalController(stand) + + player._run_core_siege(ct, stand) + + assert ct.built == [(seat, facing)] + assert ( + SURGICAL.SLOT_SIEGER_2_COUNTER_TARGET, 0 + ) in ct.writes + + +def test_surgical_counter_fallback_only_when_every_direct_plan_is_covered(): + target = SURGICAL.Position(10, 10) + direct_seat = SURGICAL.Position(7, 7) + direct_stand = SURGICAL.Position(7, 6) + direct_facing = SURGICAL.Direction.SOUTHEAST + direct_plan = ( + (0, 0, 0, 0, 0), + direct_seat, + direct_stand, + direct_facing, + ) + defender_pos = SURGICAL.Position(9, 9) + defender = ( + defender_pos.x, + defender_pos.y, + 70, + defender_pos, + SURGICAL.EntityType.GUNNER, + SURGICAL.Direction.NORTHWEST, + ) + counter_seat = SURGICAL.Position(8, 6) + counter_stand = SURGICAL.Position(8, 5) + counter_facing = SURGICAL.Direction.SOUTHEAST + player = SURGICAL.Player() + player._enemy_core_target = lambda: target + player._direct_siege_plans = lambda ct, loc, tgt: [direct_plan] + player._visible_defenders = lambda ct: [defender] + player._friendly_gunner_covers = lambda ct, tgt: False + player._safe_counter_plan = ( + lambda ct, loc, tgt, unsafe: + (counter_seat, counter_stand, counter_facing) + ) + ct = FakeSurgicalController(counter_stand) + + player._run_core_siege(ct, counter_stand) + + assert SURGICAL.direct_plans_all_covered( + [direct_plan], {direct_seat}) + assert ct.built == [(counter_seat, counter_facing)] + assert ct.store[SURGICAL.SLOT_SIEGER_2_COUNTER_TARGET] == ( + SURGICAL.pack_position(defender_pos) + ) + + +def test_generalist_stack_contains_only_the_two_passing_components(): + source = ( + ROOT / "bots" / "exp_generalist_stack" / "main.py" + ).read_text(encoding="utf-8") + assert "SLOT_SIEGER_2_COUNTER_TARGET = 13" in source + assert "SLOT_HOME_DEFENDER_ID = 14" in source + assert "SLOT_HOME_THREAT = 15" in source + assert "CORE_BUILDER_CONGESTION_LIMIT" not in source + assert "SIEGE_STALL_ROUNDS" not in source + assert "def _assign_home_defender" in source + assert "def _safe_counter_plan" in source + + +def test_frozen_generalist_and_separate_archive_identity(): + required = { + "main.py", + "initialSpawning.py", + "mapPathfinding.py", + "symmetry.py", + } + stack = ROOT / "bots" / "exp_generalist_stack" + frozen = ROOT / "bots" / "generalist-v2" + for name in required: + assert (stack / name).read_bytes() == (frozen / name).read_bytes() + with zipfile.ZipFile(ROOT / "generalist-v2.zip") as archive: + assert set(archive.namelist()) == required + for name in required: + assert archive.read(name) == (frozen / name).read_bytes() + manifest = json.loads( + (ROOT / "generalist_v2_results.json").read_text(encoding="utf-8")) + for name in required: + assert hashlib.sha256((frozen / name).read_bytes()).hexdigest().upper() == ( + manifest["sha256"][name] + ) + assert hashlib.sha256( + (ROOT / "generalist-v2.zip").read_bytes() + ).hexdigest().upper() == manifest["sha256"]["generalist-v2.zip"] + + +if __name__ == "__main__": + test_extra_spawning_stops_at_six_and_resumes_after_dispersal() + test_spawn_congestion_counts_only_nearby_friendly_builders() + test_first_five_opening_block_precedes_congestion_rule() + test_home_threat_detection_is_exact() + test_home_defender_selection_prefers_one_non_sieger_then_distance_and_id() + test_home_assignment_holds_cleans_up_and_reassigns_dead_builder() + test_surgical_counter_uses_safe_direct_plan_and_cleans_target_slot() + test_surgical_counter_fallback_only_when_every_direct_plan_is_covered() + test_generalist_stack_contains_only_the_two_passing_components() + test_frozen_generalist_and_separate_archive_identity() + print("generalist cycle tests passed") diff --git a/tests/test_late_route_repair.py b/tests/test_late_route_repair.py new file mode 100644 index 0000000..d112a7d --- /dev/null +++ b/tests/test_late_route_repair.py @@ -0,0 +1,294 @@ +"""Deterministic mechanism tests for exp_late_route_repair.""" + +from __future__ import annotations + +import importlib.util +import ast +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_late_route_repair" + + +def load_bot(): + sys.path.insert(0, str(BOT_DIR)) + try: + spec = importlib.util.spec_from_file_location( + "late_route_repair_main", BOT_DIR / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +def footprint(): + return BOT.core_footprint_positions(Position(0, 0)) + + +def test_only_actual_facing_chains_are_connected(): + conveyors = { + Position(2, 0): Direction.WEST, + Position(3, 0): Direction.WEST, + # Spatially touches the valid trunk, but points away from it. + Position(2, 1): Direction.SOUTH, + } + connected = BOT.proven_connected_conveyors(conveyors, footprint()) + assert connected == {Position(2, 0), Position(3, 0)} + + +def test_wrong_facing_harvester_neighbor_is_not_a_root(): + harvester = Position(5, 5) + conveyors = { + Position(4, 5): Direction.EAST, # outputs into the harvester + Position(5, 4): Direction.WEST, # accepts from the harvester + } + assert BOT.harvester_chain_roots(harvester, conveyors) == [ + Position(5, 4) + ] + + +def test_dead_end_is_extended_but_cycle_is_rejected(): + dead_end = { + Position(5, 5): Direction.WEST, + Position(4, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), dead_end, footprint(), set() + ) == ("dead_end", Position(3, 5)) + + cycle = { + Position(5, 5): Direction.EAST, + Position(6, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), cycle, footprint(), set() + ) == ("cycle", Position(5, 5)) + + +class ObservationController: + def __init__(self): + self.entities = {} + + def get_tile_building_id(self, position): + record = self.entities.get(position) + return None if record is None else record[0] + + def get_team(self, entity_id=None): + if entity_id is None: + return 1 + for record in self.entities.values(): + if record[0] == entity_id: + return record[1] + raise KeyError(entity_id) + + def get_entity_type(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[2] + raise KeyError(entity_id) + + def get_direction(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[3] + raise KeyError(entity_id) + + +def test_revisited_tiles_clear_stale_economy_memory(): + pathfinder = BOT.MapPathfinder() + controller = ObservationController() + conveyor = Position(3, 3) + harvester = Position(4, 4) + controller.entities[conveyor] = ( + 10, 1, EntityType.CONVEYOR, Direction.WEST) + controller.entities[harvester] = ( + 11, 1, EntityType.HARVESTER, None) + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert pathfinder.knownTeamConveyors[conveyor] == Direction.WEST + assert harvester in pathfinder.knownTeamHarvesters + + controller.entities.clear() + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert conveyor not in pathfinder.knownTeamConveyors + assert harvester not in pathfinder.knownTeamHarvesters + + +class CandidateController(ObservationController): + def __init__(self): + super().__init__() + self.builders = {} + + def is_in_vision(self, position): + return True + + def is_tile_passable(self, position): + return position not in self.builders + + def get_tile_builder_bot_id(self, position): + return self.builders.get(position) + + def get_team(self, entity_id=None): + if entity_id in self.builders.values(): + return 1 + return super().get_team(entity_id) + + +def test_served_harvester_produces_no_route_work(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(4, 0) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.WEST, + } + controller = CandidateController() + controller.entities = { + harvester: (20, 1, EntityType.HARVESTER, None), + Position(3, 0): (21, 1, EntityType.CONVEYOR, Direction.WEST), + Position(2, 0): (22, 1, EntityType.CONVEYOR, Direction.WEST), + } + assert pathfinder.lateEconomyRouteCandidates(controller) == [] + + +def test_wrong_facing_neighbor_does_not_hide_orphan_harvester(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + wrong = Position(4, 5) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = {wrong: Direction.EAST} + controller = CandidateController() + controller.entities = { + harvester: (30, 1, EntityType.HARVESTER, None), + wrong: (31, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + ends = {candidate[1] for candidate in candidates} + # The wrong-facing link is now a legal surgical rebuild target; it does + # not make the harvester appear served. + assert wrong in ends + assert ends == { + Position(4, 5), Position(5, 4), Position(5, 6), Position(6, 5) + } + + +def test_friendly_builder_on_missing_link_keeps_route_candidate_alive(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + occupied_end = Position(5, 4) + pathfinder.knownTeamHarvesters = {harvester} + controller = CandidateController() + controller.entities = { + harvester: (40, 1, EntityType.HARVESTER, None), + } + controller.builders[occupied_end] = 41 + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert occupied_end in {candidate[1] for candidate in candidates} + + +def test_orphan_cycle_exposes_one_disconnected_link_for_rebuild(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + cycle = { + Position(6, 5): Direction.SOUTH, + Position(6, 6): Direction.WEST, + Position(5, 6): Direction.EAST, + } + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = cycle + controller = CandidateController() + controller.entities = { + harvester: (50, 1, EntityType.HARVESTER, None), + Position(6, 5): (51, 1, EntityType.CONVEYOR, Direction.SOUTH), + Position(6, 6): (52, 1, EntityType.CONVEYOR, Direction.WEST), + Position(5, 6): (53, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert Position(6, 6) in {candidate[1] for candidate in candidates} + + +def test_late_repair_is_bounded_and_below_direct_threat_priority(): + allowed = BOT.late_route_repair_allowed + assert not allowed(79, 500, 1) + assert allowed(80, BOT.LATE_ROUTE_RESOURCE_FLOOR, + BOT.LATE_ROUTE_MAX_LINKS) + assert not allowed(80, BOT.LATE_ROUTE_RESOURCE_FLOOR - 1, 1) + assert not allowed(80, 500, BOT.LATE_ROUTE_MAX_LINKS + 1) + assert BOT.LATE_ROUTE_SCORE_CAP < 10 + + +def test_one_nearest_builder_claims_a_late_repair(): + target = Position(5, 5) + candidates = [ + (9, Position(3, 5)), + (7, Position(5, 3)), + (4, Position(9, 9)), + ] + assert BOT.choose_route_repairer(candidates, target) == 7 + assert BOT.choose_route_repairer(candidates, target, {7, 9}) == 4 + assert BOT.choose_route_repairer([], target) is None + + +def test_experiment_preserves_protocol_and_has_no_fingerprints(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + source = "\n".join( + (BOT_DIR / name).read_text(encoding="utf-8").lower() + for name in ("main.py", "mapPathfinding.py") + ) + for fingerprint in ("string.map", "bridge.map", "duel.map", "ijti"): + assert fingerprint not in source + + +def method_ast(path, class_name, method_name): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return ast.dump(child, include_attributes=False) + raise AssertionError(f"missing {class_name}.{method_name}") + + +def test_parent_opening_conveyor_router_is_unchanged(): + parent = ROOT / "bots" / "meta-generalist-v1" / "mapPathfinding.py" + candidate = BOT_DIR / "mapPathfinding.py" + for method in ( + "conveyorTileCost", "fillConveyorDistTable", "routeConveyor"): + assert method_ast(candidate, "MapPathfinder", method) == method_ast( + parent, "MapPathfinder", method) + + +if __name__ == "__main__": + test_only_actual_facing_chains_are_connected() + test_wrong_facing_harvester_neighbor_is_not_a_root() + test_dead_end_is_extended_but_cycle_is_rejected() + test_revisited_tiles_clear_stale_economy_memory() + test_served_harvester_produces_no_route_work() + test_wrong_facing_neighbor_does_not_hide_orphan_harvester() + test_friendly_builder_on_missing_link_keeps_route_candidate_alive() + test_orphan_cycle_exposes_one_disconnected_link_for_rebuild() + test_late_repair_is_bounded_and_below_direct_threat_priority() + test_one_nearest_builder_claims_a_late_repair() + test_experiment_preserves_protocol_and_has_no_fingerprints() + test_parent_opening_conveyor_router_is_unchanged() + print("late route repair tests passed") diff --git a/tests/test_meta_armor_rebuild.py b/tests/test_meta_armor_rebuild.py new file mode 100644 index 0000000..1b2e2f2 --- /dev/null +++ b/tests/test_meta_armor_rebuild.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_armor_rebuild" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location("meta_armor", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +def action(threat, claimed, barrier, damaged, empty): + return META.armor_maintenance_action( + threat, claimed, barrier, damaged, empty) + + +def test_maintenance_is_strictly_threat_gated(): + assert action(False, True, True, True, False) is None + assert action(False, True, False, False, True) is None + + +def test_damaged_claimed_barrier_is_healed(): + assert action(True, True, True, True, False) == "heal" + assert action(True, True, True, False, False) is None + + +def test_destroyed_claimed_barrier_is_rebuilt_not_expanded(): + assert action(True, True, False, False, True) == "rebuild" + assert action(True, False, False, False, True) is None + assert action(True, True, False, False, False) is None + + +def test_supporting_modules_match_prearmor_parent(): + parent = ROOT / "bots" / "exp_prearmor" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_maintenance_is_strictly_threat_gated() + test_damaged_claimed_barrier_is_healed() + test_destroyed_claimed_barrier_is_rebuilt_not_expanded() + test_supporting_modules_match_prearmor_parent() + print("armor rebuild tests passed") diff --git a/tests/test_meta_breacher.py b/tests/test_meta_breacher.py new file mode 100644 index 0000000..0236ef8 --- /dev/null +++ b/tests/test_meta_breacher.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from fcode import EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_breacher" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location("meta_breacher", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +class BlockerController: + def __init__(self, kind=EntityType.BARRIER, enemy=True, firing=True): + self.kind = kind + self.enemy = enemy + self.firing = firing + self.position = Position(7, 10) + + def get_team(self, entity_id=None): + return 1 if entity_id is not None and self.enemy else 0 + + def get_nearby_buildings(self): + return [4] + + def get_entity_type(self, entity_id): + return self.kind + + def get_position(self, entity_id): + return self.position + + def can_fire_from(self, seat, facing, kind, target): + return self.firing and seat == self.position and kind == EntityType.GUNNER + + +def blocker(kind=EntityType.BARRIER, enemy=True, firing=True): + ct = BlockerController(kind, enemy, firing) + player = META.Player() + return player._visible_core_seat_blocker(ct, Position(10, 10)) + + +def test_only_enemy_barrier_on_real_core_seat_is_selected(): + assert blocker() == Position(7, 10) + assert blocker(enemy=False) is None + assert blocker(kind=EntityType.CONVEYOR) is None + assert blocker(firing=False) is None + + +def test_supporting_modules_match_parent(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_only_enemy_barrier_on_real_core_seat_is_selected() + test_supporting_modules_match_parent() + print("breacher tests passed") diff --git a/tests/test_meta_builder_targeting.py b/tests/test_meta_builder_targeting.py new file mode 100644 index 0000000..ba3bb5a --- /dev/null +++ b/tests/test_meta_builder_targeting.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from fcode import Direction, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_builder_targeting" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location("meta_builder_target", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +class CurrentBuilderTarget: + def __init__(self, friendly=False): + self.friendly = friendly + self.fired = None + self.target = Position(0, 2) + + def get_gunner_target(self): return self.target + def get_direction(self): return Direction.NORTH + def get_position(self): return Position(0, 0) + def get_team(self, entity_id=None): + if entity_id == 7: + return 0 if self.friendly else 1 + return 0 + def get_tile_building_id(self, position): return None + def get_tile_builder_bot_id(self, position): return 7 + def can_fire(self, position): return position == self.target + def fire(self, position): self.fired = position + def get_global_resources(self): return 0 + + +def test_gunner_fires_enemy_builder_on_empty_tile(): + ct = CurrentBuilderTarget() + META.Player().runGunner(ct) + assert ct.fired == ct.target + + +def test_gunner_never_fires_friendly_builder(): + ct = CurrentBuilderTarget(friendly=True) + META.Player().runGunner(ct) + assert ct.fired is None + + +def test_supporting_modules_match_parent(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_gunner_fires_enemy_builder_on_empty_tile() + test_gunner_never_fires_friendly_builder() + test_supporting_modules_match_parent() + print("builder targeting tests passed") diff --git a/tests/test_meta_emergency_ammo.py b/tests/test_meta_emergency_ammo.py new file mode 100644 index 0000000..c260d79 --- /dev/null +++ b/tests/test_meta_emergency_ammo.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_emergency_ammo" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location("meta_ammo", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +def test_emergency_conversion_preserves_reserve(): + assert META.emergency_ammo_conversion(0, 40, True) == 12 + assert META.emergency_ammo_conversion(7, 100, True) == 13 + assert META.emergency_ammo_conversion(20, 100, True) == 0 + assert META.emergency_ammo_conversion(0, 100, False) == 0 + + +def test_only_main_and_readme_change(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_emergency_conversion_preserves_reserve() + test_only_main_and_readme_change() + print("emergency ammo tests passed") diff --git a/tests/test_meta_gunner_aim.py b/tests/test_meta_gunner_aim.py new file mode 100644 index 0000000..501f8a7 --- /dev/null +++ b/tests/test_meta_gunner_aim.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_gunner_aim" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location("meta_aim", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +class Controller: + def __init__(self, threat=True, resources=26): + self.threat = threat + self.resources = resources + self.rotated = None + + def get_gunner_target(self): return None + def get_direction(self): return Direction.NORTH + def get_position(self): return Position(0, 0) + def get_team(self, entity_id=None): return 0 + def read_store(self, slot): + return (META.pack_position(Position(2, 0)) + if self.threat and slot == META.SLOT_HOME_THREAT else 0) + def can_fire_from(self, pos, direction, kind, target): + return (kind == EntityType.GUNNER and direction == Direction.EAST + and target == Position(2, 0)) + def get_global_resources(self): return self.resources + def can_rotate(self, direction): return direction == Direction.EAST + def rotate(self, direction): self.rotated = direction + + +def test_exact_home_threat_rotates_gunner_at_defense_floor(): + ct = Controller() + META.Player().runGunner(ct) + assert ct.rotated == Direction.EAST + + +def test_absent_threat_does_not_use_defense_floor(): + ct = Controller(threat=False) + META.Player().runGunner(ct) + assert ct.rotated is None + + +def test_only_main_and_readme_change(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_exact_home_threat_rotates_gunner_at_defense_floor() + test_absent_threat_does_not_use_defense_floor() + test_only_main_and_readme_change() + print("home gunner aiming tests passed") diff --git a/tests/test_meta_gunner_defense.py b/tests/test_meta_gunner_defense.py new file mode 100644 index 0000000..cfe01cf --- /dev/null +++ b/tests/test_meta_gunner_defense.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_gunner_defense" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location( + "exp_meta_gunner_defense_main", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +class GunnerController: + def __init__(self, threat=True, resources=26): + self.threat = threat + self.resources = resources + self.rotated = None + + def get_gunner_target(self): + return None + + def get_direction(self): + return Direction.NORTH + + def get_position(self): + return Position(0, 0) + + def get_team(self, entity_id=None): + return 0 + + def read_store(self, slot): + if slot == META.SLOT_HOME_THREAT and self.threat: + return META.pack_position(Position(2, 0)) + return 0 + + def can_fire_from(self, position, direction, entity_type, target): + return ( + entity_type == EntityType.GUNNER + and target == Position(2, 0) + and direction == Direction.EAST + ) + + def get_global_resources(self): + return self.resources + + def can_rotate(self, direction): + return direction == Direction.EAST + + def rotate(self, direction): + self.rotated = direction + + +def test_emergency_ammo_preserves_titanium_reserve(): + assert META.emergency_ammo_conversion(0, 40, True) == 12 + assert META.emergency_ammo_conversion(7, 100, True) == 13 + assert META.emergency_ammo_conversion(20, 100, True) == 0 + assert META.emergency_ammo_conversion(0, 100, False) == 0 + + +def test_home_gunner_rotates_at_emergency_floor(): + controller = GunnerController() + META.Player().runGunner(controller) + assert controller.rotated == Direction.EAST + + +def test_no_threat_does_not_use_emergency_rotation(): + controller = GunnerController(threat=False, resources=26) + META.Player().runGunner(controller) + assert controller.rotated is None + + +def test_experiment_changes_only_main_and_readme(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_emergency_ammo_preserves_titanium_reserve() + test_home_gunner_rotates_at_emergency_floor() + test_no_threat_does_not_use_emergency_rotation() + test_experiment_changes_only_main_and_readme() + print("home gunner tests passed") diff --git a/tests/test_meta_late_reinforce.py b/tests/test_meta_late_reinforce.py new file mode 100644 index 0000000..27e3c95 --- /dev/null +++ b/tests/test_meta_late_reinforce.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_late_reinforce" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location( + "exp_meta_late_reinforce_main", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +def test_existing_economy_never_converts(): + assert not META.is_late_reinforcement(1, False, False) + assert not META.is_late_reinforcement(META.ECON_CUTOFF - 1, False, False) + + +def test_post_transition_production_converts(): + assert META.is_late_reinforcement(META.ECON_CUTOFF, False, False) + assert META.is_late_reinforcement(META.ECON_CUTOFF + 100, False, False) + + +def test_special_roles_are_not_reclassified(): + assert not META.is_late_reinforcement(META.ECON_CUTOFF, True, False) + assert not META.is_late_reinforcement(META.ECON_CUTOFF, False, True) + + +def test_experiment_changes_only_main_and_readme(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_existing_economy_never_converts() + test_post_transition_production_converts() + test_special_roles_are_not_reclassified() + test_experiment_changes_only_main_and_readme() + print("late reinforcement tests passed") diff --git a/tests/test_meta_reinforce.py b/tests/test_meta_reinforce.py new file mode 100644 index 0000000..4d1db55 --- /dev/null +++ b/tests/test_meta_reinforce.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOT = ROOT / "bots" / "exp_meta_reinforce" + + +def load_main(): + sys.path.insert(0, str(BOT)) + try: + spec = importlib.util.spec_from_file_location( + "exp_meta_reinforce_main", BOT / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +META = load_main() + + +def policy(round_no, spawned=5, waller=False, committed=False, contact=False): + return META.should_commit_reinforcement( + round_no, spawned, waller, committed, contact) + + +def test_opening_is_preserved_without_contact(): + assert not policy(17, contact=True) + assert not policy(18, spawned=4, contact=True) + assert not policy(39) + + +def test_mature_contact_can_trigger_early_conversion(): + assert policy(18, contact=True) + assert policy(39, contact=True) + + +def test_normal_transition_converts_without_contact(): + assert not policy(39) + assert policy(40) + assert policy(100) + + +def test_commitment_latches_but_never_claims_the_waller(): + assert policy(25, committed=True) + assert not policy(100, waller=True, committed=True, contact=True) + + +def test_experiment_changes_only_main_and_readme(): + parent = ROOT / "bots" / "exp_trans_40" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT / name).read_bytes() == (parent / name).read_bytes() + + +if __name__ == "__main__": + test_opening_is_preserved_without_contact() + test_mature_contact_can_trigger_early_conversion() + test_normal_transition_converts_without_contact() + test_commitment_latches_but_never_claims_the_waller() + test_experiment_changes_only_main_and_readme() + print("meta reinforcement tests passed") diff --git a/tests/test_v89_vulnerability_cycle.py b/tests/test_v89_vulnerability_cycle.py new file mode 100644 index 0000000..989440b --- /dev/null +++ b/tests/test_v89_vulnerability_cycle.py @@ -0,0 +1,496 @@ +"""Deterministic tests for replay-derived v89 vulnerability experiments.""" + +from pathlib import Path +import hashlib +import importlib.util +import json +import sys +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_bot(name): + bot_dir = ROOT / "bots" / name + sys.path.insert(0, str(bot_dir)) + try: + spec = importlib.util.spec_from_file_location( + f"{name}_main", bot_dir / "main.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +SEQUENTIAL = load_bot("exp_sequential_responder") +HEALER = load_bot("exp_healer_suppression") +STICKY = load_bot("exp_healer_suppression_sticky") +INTERCEPT = load_bot("exp_healer_intercept") +RISK_PARITY = load_bot("exp_healer_risk_parity") +BOUNDED_HOME = load_bot("exp_bounded_home_counter") +EMERGENCY_TRADE = load_bot("exp_emergency_countertrade") + + +def threat(module, x, y, entity_id): + pos = module.Position(x, y) + return (0, x, y, entity_id, pos) + + +def test_sequential_responder_selects_first_uncovered_threat(): + threats = [ + threat(SEQUENTIAL, 3, 4, 10), + threat(SEQUENTIAL, 4, 4, 11), + threat(SEQUENTIAL, 5, 4, 12), + ] + assert SEQUENTIAL.choose_home_threat( + threats, {SEQUENTIAL.Position(3, 4)} + ) == SEQUENTIAL.Position(4, 4) + assert SEQUENTIAL.choose_home_threat( + threats, + {SEQUENTIAL.Position(3, 4), SEQUENTIAL.Position(4, 4)}, + ) == SEQUENTIAL.Position(5, 4) + + +def test_sequential_responder_retains_assignment_when_all_are_covered(): + threats = [ + threat(SEQUENTIAL, 3, 4, 10), + threat(SEQUENTIAL, 4, 4, 11), + ] + covered = {item[4] for item in threats} + assert SEQUENTIAL.choose_home_threat( + threats, covered + ) == SEQUENTIAL.Position(3, 4) + assert SEQUENTIAL.choose_home_threat([], covered) is None + + +def test_sequential_experiment_changes_only_responder_target_selection(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_sequential_responder" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + + baseline_source = (baseline / "main.py").read_text(encoding="utf-8") + experiment_source = (experiment / "main.py").read_text(encoding="utf-8") + assert "def choose_home_threat(" not in baseline_source + assert "def choose_home_threat(" in experiment_source + assert "SLOT_HOME_DEFENDER_ID = 14" in experiment_source + assert "SLOT_HOME_THREAT = 15" in experiment_source + assert "HOME_DEFENDER_HOLD_ROUNDS = 5" in experiment_source + assert "healer" not in experiment_source.lower() + + +class FakeHealerSuppressionController: + def __init__(self): + self.round = 50 + self.core_hp = 500 + self.target = HEALER.Position(10, 10) + self.healer = HEALER.Position(9, 10) + self.gunner = HEALER.Position(7, 10) + + def get_team(self, entity_id=None): + if entity_id in (100, 200): + return 1 + return 0 + + def is_in_vision(self, position): + return True + + def get_tile_building_id(self, position): + if position in HEALER.core_footprint(self.target): + return 100 + return None + + def get_entity_type(self, entity_id): + return { + 100: HEALER.EntityType.CORE, + 200: HEALER.EntityType.BUILDER_BOT, + 300: HEALER.EntityType.GUNNER, + }[entity_id] + + def get_nearby_units(self): + return [200] + + def get_nearby_buildings(self): + return [100, 300] + + def get_position(self, entity_id): + return {100: self.target, 200: self.healer, 300: self.gunner}[entity_id] + + def get_direction(self, entity_id): + assert entity_id == 300 + return HEALER.Direction.EAST + + def get_attackable_tiles_from(self, position, direction, entity_type): + assert position == self.gunner + assert entity_type == HEALER.EntityType.GUNNER + return HEALER.core_footprint(self.target) + + def get_hp(self, entity_id): + assert entity_id == 100 + return self.core_hp + + def get_current_round(self): + return self.round + + +def test_healer_stall_requires_direct_pressure_and_visible_healer(): + assert HEALER.next_healer_stall_count(None, 500, False, 1, 4) == 0 + assert HEALER.next_healer_stall_count(None, 500, True, 0, 4) == 0 + assert HEALER.next_healer_stall_count(None, 500, True, 1, 0) == 1 + assert HEALER.next_healer_stall_count(500, 500, True, 1, 2) == 3 + assert HEALER.next_healer_stall_count(496, 500, True, 1, 2) == 3 + assert HEALER.next_healer_stall_count(500, 496, True, 1, 5) == 0 + + +def test_healer_suppression_activates_after_six_stalled_observations(): + controller = FakeHealerSuppressionController() + player = HEALER.Player() + my_loc = HEALER.Position(7, 11) + for _ in range(HEALER.HEALER_STALL_ROUNDS - 1): + assert player._healer_suppression_target( + controller, my_loc, controller.target) is None + controller.round += 1 + assert player._healer_suppression_target( + controller, my_loc, controller.target) == controller.healer + + player._healerSuppressionUntil = controller.round + 12 + assert player._healer_suppression_target( + controller, my_loc, controller.target) is None + + +def test_healer_experiment_is_independent_from_responder_experiment(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_healer_suppression" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + source = (experiment / "main.py").read_text(encoding="utf-8") + assert "def next_healer_stall_count(" in source + assert "def choose_home_threat(" not in source + assert "HEALER_STALL_ROUNDS = 6" in source + assert "HEALER_SUPPRESSION_COOLDOWN = 12" in source + + +def test_sticky_healer_target_persists_boundedly_after_trigger(): + controller = FakeHealerSuppressionController() + player = STICKY.Player() + my_loc = STICKY.Position(7, 11) + for _ in range(STICKY.HEALER_STALL_ROUNDS): + selected = player._healer_suppression_target( + controller, my_loc, controller.target) + controller.round += 1 + assert selected == controller.healer + assert player._healerTargetId == 200 + + controller.core_hp = 496 + assert player._healer_suppression_target( + controller, my_loc, controller.target) == controller.healer + + controller.round = player._healerTargetUntil + 1 + assert player._healer_suppression_target( + controller, my_loc, controller.target) is None + assert player._healerTargetId is None + + +def test_sticky_healer_experiment_remains_isolated(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_healer_suppression_sticky" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + source = (experiment / "main.py").read_text(encoding="utf-8") + assert "HEALER_PURSUIT_ROUNDS = 10" in source + assert "def choose_home_threat(" not in source + assert "_healerTargetUntil" in source + + +class FakeInterceptController: + def __init__(self, hits): + self.hits = set(hits) + + def can_fire_from(self, seat, facing, entity_type, target): + assert entity_type == INTERCEPT.EntityType.GUNNER + return (seat, facing, target) in self.hits + + +def test_healer_intercept_uses_only_direct_capable_plans_and_prefers_safe(): + healer = INTERCEPT.Position(10, 10) + unsafe_seat = INTERCEPT.Position(7, 10) + unsafe_stand = INTERCEPT.Position(7, 11) + safe_seat = INTERCEPT.Position(8, 8) + safe_stand = INTERCEPT.Position(8, 9) + misses = INTERCEPT.Position(6, 6) + plans = [ + ((0, 1), unsafe_seat, unsafe_stand, INTERCEPT.Direction.EAST), + ((1, 1), safe_seat, safe_stand, INTERCEPT.Direction.SOUTHEAST), + ((2, 1), misses, INTERCEPT.Position(6, 7), INTERCEPT.Direction.EAST), + ] + controller = FakeInterceptController({ + (unsafe_seat, INTERCEPT.Direction.EAST, healer), + (safe_seat, INTERCEPT.Direction.SOUTHEAST, healer), + }) + player = INTERCEPT.Player() + selected = player._healer_intercept_plans( + controller, plans, healer, {unsafe_seat, unsafe_stand}) + assert [plan[1] for plan in selected] == [safe_seat, unsafe_seat] + assert misses not in [plan[1] for plan in selected] + + +def test_healer_intercept_experiment_remains_independent(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_healer_intercept" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + source = (experiment / "main.py").read_text(encoding="utf-8") + assert "def _healer_intercept_plans(" in source + assert "def choose_home_threat(" not in source + assert "_safe_counter_plan(\n ct, myLoc, healer_target" not in source + + +class FakeRiskParityController: + def __init__(self, seat, facing, target): + self.seat = seat + self.facing = facing + self.target = target + + def get_nearby_tiles(self): + return [self.seat] + + def get_tile_env(self, position): + return RISK_PARITY.Environment.EMPTY + + def get_tile_building_id(self, position): + return None + + def can_fire_from(self, seat, facing, entity_type, target): + return ( + seat == self.seat + and facing == self.facing + and entity_type == RISK_PARITY.EntityType.GUNNER + and target == self.target + ) + + def is_in_vision(self, position): + return True + + def get_tile_builder_bot_id(self, position): + return None + + def is_tile_passable(self, position): + return True + + +def test_healer_risk_parity_never_exceeds_best_direct_exposure(): + seat = RISK_PARITY.Position(8, 8) + stand = seat.add(RISK_PARITY.Direction.NORTH) + target = RISK_PARITY.Position(10, 10) + facing = RISK_PARITY.Direction.SOUTHEAST + controller = FakeRiskParityController(seat, facing, target) + player = RISK_PARITY.Player() + player.mapW = 20 + player.mapH = 20 + suppression_unsafe = {seat} + suppression_unsafe.update( + seat.add(direction) for direction in RISK_PARITY.CARDINALS + ) + direct_safe = [ + ((0,), RISK_PARITY.Position(7, 7), + RISK_PARITY.Position(7, 8), RISK_PARITY.Direction.SOUTHEAST), + ] + assert player._risk_parity_healer_plan( + controller, + stand, + target, + suppression_unsafe, + direct_safe, + ) is None + + direct_covered = [ + ((0,), RISK_PARITY.Position(7, 7), + RISK_PARITY.Position(7, 8), RISK_PARITY.Direction.SOUTHEAST), + ] + unsafe = suppression_unsafe | { + direct_covered[0][1], + direct_covered[0][2], + } + assert player._risk_parity_healer_plan( + controller, stand, target, unsafe, direct_covered + ) == (seat, stand, facing) + + +def test_healer_risk_parity_experiment_remains_independent(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_healer_risk_parity" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + source = (experiment / "main.py").read_text(encoding="utf-8") + assert "def _risk_parity_healer_plan(" in source + assert "def choose_home_threat(" not in source + + +class FakeBoundedHomeController: + def __init__(self, seat, facing, target): + self.seat = seat + self.facing = facing + self.target = target + + def get_nearby_tiles(self): + return [self.seat] + + def get_tile_env(self, position): + return BOUNDED_HOME.Environment.EMPTY + + def get_tile_building_id(self, position): + return None + + def can_fire_from(self, seat, facing, entity_type, target): + return ( + seat == self.seat + and facing == self.facing + and entity_type == BOUNDED_HOME.EntityType.GUNNER + and target == self.target + ) + + def is_in_vision(self, position): + return True + + def get_tile_builder_bot_id(self, position): + return None + + def is_tile_passable(self, position): + return True + + +def test_bounded_home_counter_requires_multiple_turrets_and_safe_builder(): + seat = BOUNDED_HOME.Position(8, 8) + stand = seat.add(BOUNDED_HOME.Direction.NORTH) + target = BOUNDED_HOME.Position(10, 10) + facing = BOUNDED_HOME.Direction.SOUTHEAST + controller = FakeBoundedHomeController(seat, facing, target) + player = BOUNDED_HOME.Player() + player.mapW = 20 + player.mapH = 20 + + one_turret = [(1, 1, 10, {seat})] + assert player._bounded_home_counter_plan( + controller, stand, target, one_turret + ) is None + + two_turrets = [ + (1, 1, 10, {seat}), + (2, 2, 11, set()), + ] + assert player._bounded_home_counter_plan( + controller, stand, target, two_turrets + ) == (seat, stand, facing) + + both_cover_seat = [ + (1, 1, 10, {seat}), + (2, 2, 11, {seat}), + ] + assert player._bounded_home_counter_plan( + controller, stand, target, both_cover_seat + ) is None + + unsafe_stands = { + seat.add(direction) for direction in BOUNDED_HOME.CARDINALS + } + builder_exposed = [ + (1, 1, 10, {seat} | unsafe_stands), + (2, 2, 11, set()), + ] + assert player._bounded_home_counter_plan( + controller, stand, target, builder_exposed + ) is None + + +def test_bounded_home_counter_experiment_remains_independent(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_bounded_home_counter" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + source = (experiment / "main.py").read_text(encoding="utf-8") + assert "def _bounded_home_counter_plan(" in source + assert "def _healer_suppression_target(" not in source + assert "def choose_home_threat(" not in source + + +def test_emergency_countertrade_waives_only_the_bounded_trade_reserve(): + assert not EMERGENCY_TRADE.home_counter_budget_allows(90, False) + assert EMERGENCY_TRADE.home_counter_budget_allows(90, True) + assert EMERGENCY_TRADE.home_counter_budget_allows(121, False) + + seat = EMERGENCY_TRADE.Position(8, 8) + stand = seat.add(EMERGENCY_TRADE.Direction.NORTH) + target = EMERGENCY_TRADE.Position(10, 10) + facing = EMERGENCY_TRADE.Direction.SOUTHEAST + controller = FakeBoundedHomeController(seat, facing, target) + player = EMERGENCY_TRADE.Player() + player.mapW = 20 + player.mapH = 20 + turret_attacks = [ + (1, 1, 10, {seat}), + (2, 2, 11, set()), + ] + assert player._bounded_home_counter_plan( + controller, stand, target, turret_attacks + ) == (seat, stand, facing) + + +def test_emergency_countertrade_experiment_remains_independent(): + baseline = ROOT / "bots" / "generalist-v2" + experiment = ROOT / "bots" / "exp_emergency_countertrade" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (baseline / name).read_bytes() == (experiment / name).read_bytes() + source = (experiment / "main.py").read_text(encoding="utf-8") + assert "def _bounded_home_counter_plan(" in source + assert "def home_counter_budget_allows(" in source + assert "def _healer_suppression_target(" not in source + assert "def choose_home_threat(" not in source + + +def test_generalist_v3_freeze_matches_passing_experiment(): + required = { + "main.py", + "initialSpawning.py", + "mapPathfinding.py", + "symmetry.py", + } + experiment = ROOT / "bots" / "exp_emergency_countertrade" + frozen = ROOT / "bots" / "generalist-v3" + for name in required: + assert (experiment / name).read_bytes() == (frozen / name).read_bytes() + with zipfile.ZipFile(ROOT / "generalist-v3.zip") as archive: + assert set(archive.namelist()) == required + for name in required: + assert archive.read(name) == (frozen / name).read_bytes() + manifest = json.loads( + (ROOT / "generalist_v3_results.json").read_text(encoding="utf-8")) + for name in required: + assert hashlib.sha256( + (frozen / name).read_bytes() + ).hexdigest().upper() == manifest["sha256"][name] + assert hashlib.sha256( + (ROOT / "generalist-v3.zip").read_bytes() + ).hexdigest().upper() == manifest["sha256"]["generalist-v3.zip"] + + +if __name__ == "__main__": + test_sequential_responder_selects_first_uncovered_threat() + test_sequential_responder_retains_assignment_when_all_are_covered() + test_sequential_experiment_changes_only_responder_target_selection() + test_healer_stall_requires_direct_pressure_and_visible_healer() + test_healer_suppression_activates_after_six_stalled_observations() + test_healer_experiment_is_independent_from_responder_experiment() + test_sticky_healer_target_persists_boundedly_after_trigger() + test_sticky_healer_experiment_remains_isolated() + test_healer_intercept_uses_only_direct_capable_plans_and_prefers_safe() + test_healer_intercept_experiment_remains_independent() + test_healer_risk_parity_never_exceeds_best_direct_exposure() + test_healer_risk_parity_experiment_remains_independent() + test_bounded_home_counter_requires_multiple_turrets_and_safe_builder() + test_bounded_home_counter_experiment_remains_independent() + test_emergency_countertrade_waives_only_the_bounded_trade_reserve() + test_emergency_countertrade_experiment_remains_independent() + test_generalist_v3_freeze_matches_passing_experiment() + print("v89 vulnerability cycle tests passed") From 1163bc9235676dd165c28b2c2eb2f0c19f21ea27 Mon Sep 17 00:00:00 2001 From: Subodh Date: Mon, 3 Aug 2026 09:39:09 +0200 Subject: [PATCH 16/30] add v17 gun-control successor --- HANDOFF.md | 159 ++++- bots/exp_v17_gunner_control/README.md | 19 + bots/exp_v17_gunner_control/main.py | 520 ++++++++++++++ bots/exp_v17_gunner_control/mapPathfinding.py | 670 ++++++++++++++++++ bots/live-v17-control/README.md | 11 + bots/live-v17-control/main.py | 510 +++++++++++++ bots/live-v17-control/mapPathfinding.py | 670 ++++++++++++++++++ live_replay_audit.py | 20 + tests/test_v17_gunner_control.py | 116 +++ 9 files changed, 2663 insertions(+), 32 deletions(-) create mode 100644 bots/exp_v17_gunner_control/README.md create mode 100644 bots/exp_v17_gunner_control/main.py create mode 100644 bots/exp_v17_gunner_control/mapPathfinding.py create mode 100644 bots/live-v17-control/README.md create mode 100644 bots/live-v17-control/main.py create mode 100644 bots/live-v17-control/mapPathfinding.py create mode 100644 tests/test_v17_gunner_control.py diff --git a/HANDOFF.md b/HANDOFF.md index c106375..02cc5e4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,24 +1,25 @@ # FCode production handoff -Updated 2026-08-02. Read this before changing a bot. `AGENTS.md` contains the +Updated 2026-08-03. Read this before changing a bot. `AGENTS.md` contains the iteration rules; `WORKFLOW.md` contains the gate protocol. ## Current decision -- Strongest local candidate: `bots/meta-generalist-v1`. -- Parent: `bots/exp_trans_40`, the shipped-v8 source lineage. -- Only change: gunners now recognize enemy builders on the engine's separate - builder layer. The parent fired/rotated only when an enemy building occupied - the tile; a lone delivery builder was invisible. -- Frozen package: `meta-generalist-v1.zip`; manifest: - `meta_generalist_v1_results.json`. -- Local only. It has not been uploaded, queued, activated, or copied to root. +- Frozen ladder control: `bots/live-v17-control`, downloaded byte-for-byte from + active production submission `v17 (dumb bot v6)`. Original archive: + `C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v17-20260803/v17-live.zip`. +- Latest observed production status on 2026-08-03: Erebus rank #3/92, rating + 1777, 287 matches, latest ten series 10W-0L. +- Current local candidate: `bots/exp_v17_gunner_control`. It changes only + gunner target/facing control and decisively beat exact v17; details below. +- `meta-generalist-v1` remains the exact archived v9 control, but is no longer + the parent for new work. - Root `main.py` / `bot.zip` remain the historical v88 package and are dirty in git from prior user work. Do not replace them without explicit approval. -- Production status cannot be queried with the current CLI session: the local - WSL environment is staging `fcode 2.3.2.dev29` and its session is expired. - The user says the real competition has started. Re-authenticate/update before - any remote verification and never describe local dev29 gates as production. +- Local WSL is production `fcode 2.3.3` with all 21 maps and an authenticated + production session. The ten completed v9 ladder series (50 games) are in + `C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v9-20260802-1830/`. + Gates below distinguish old dev29 evidence from new 2.3.3 evidence. ## Candidate evidence @@ -67,11 +68,81 @@ SHA-256: | `exp_siege_on_sight` | shipped live v2; 67%/336 vs v3, 52% vs rush | | `exp_recall2` | soft v6 recall; hard recall rejected | | `exp_trans_40` | shipped v8 lineage; economy stops at t40; 60%/504 vs recall parent, but live top-five trade rather than clear gain | -| `meta-generalist-v1` | current local candidate; gunner builder-layer bug fix | - -The exact active production version must be verified after re-authentication. -Commit messages say transition40 was shipped as v8; do not infer activation from -the local root files. +| `meta-generalist-v1` | archived v9 control; gunner builder-layer bug fix | +| `live-v17-control` | active v17; four-builder rush/defense/economy chassis; rank #3 at freeze | +| `exp_v17_gunner_control` | validated v17 successor candidate; local only | + +## Live v17 replay audit and candidate + +Thirty ladder games from the six series actually played by v17 were downloaded +to `C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v17-20260803/`. +The sample was 23-7 against Pantheon, Askar City, team lazy, CtrlAltDefeat, +the one piece, and Powered by SmartFridge. + +All seven losses were core destruction under multiple enemy gunners. Five were +early races ending on turns 28-68; one Sprint game lost on turn 301 after 580 +enemy-core damage was partly healed; one Sweden game lost on turn 575 after +building 52 gunners but never damaging the enemy core. Only one or two home +turrets existed before the first incoming shot in every loss. This indicates +three remaining categories: early gun-line control, healed/stalled conversion, +and rare enemy-core discovery/pathing failure. + +Source audit found three general gunner bugs: v17 ignored lone enemy builders; +equal target scores could rotate away from the current line and waste 10 Ti; +and a gun countering a real core-threatening gun received only a lower spend +floor, not targeting priority. `exp_v17_gunner_control` fixes exactly these. + +| Production 2.3.3 gate | Candidate | Exact v17 control | Candidate core kills | +|---|---:|---:|---:| +| head-to-head | **200/336 (59.5%)** | 136/336 | **187-128** | +| vs `oogbest-v6` | **145/168 (86%)** | 140/168 (83%) | **143-18** vs 139-25 | +| vs `meta-generalist-v1` | **144/168 (86%)** | 142/168 (85%) | **142-19** vs 140-25 | + +The candidate passes deterministic targeting/tie/priority tests, compiles, and +completed a smoke game. It has not been packaged, uploaded, or activated. + +## Conveyor iteration on production engine 2.3.3 + +Live observation and local replay audits confirmed the parent can build many +conveyors while leaving harvesters unserved. Causes in the inherited router: +any adjacent conveyor counts as connected, arbitrary visible conveyors are +merge sinks, and the round-40 cutoff abandons incomplete paths. Defensive +conveyors can also form wrong-facing loops. + +`conveyor_audit.py` now reports facing-proven connections, dead ends, cycles, +backfeeding roots, served harvesters, and post-round-40 construction from +`.replay26` files. + +| Experiment | Parent result | Long-game result | Decision | +|---|---:|---:|---| +| `exp_connected_economy` | 3/12, kills 3-9 | broad rewrite | reject: opening changed | +| `exp_late_route_repair` | 83/168, kills 64-79 | 19/25; Ti 4838-2785 | mechanism pass, combat fail | +| `exp_late_route_idle` | 81/168, kills 64-77 | 17/27; Ti 4017-3496 | reject | +| `exp_stalemate_route_repair` | 82/168, kills 70-71 | 12/26; Ti 3528-3565 | round 300 too late | +| `exp_waller_route_repair` | rush screen 2/12 | hot scan from round 1 | reject: CPU/hot-path pollution | +| `exp_waller_route_light` | **256/504 (50.8%)**, kills **210-211** | **45/81**, Ti **3328-3245** | hold: misses kill gate by one | +| `exp_route_integrity` | 85/168, kills 71-74 | Sweden 7/8 | reject: topology guard became a specialist trade | +| `exp_waller_route_reserve` | fast 5/12, kills 5-7 | not gated | reject before full gate | + +The final lightweight mechanism observes the graph only on the non-attacking +waller after round 120, defers to healing, repairs at most eight links, merges +only into a facing-proven empty trunk, and can rebuild one orphan-rooted cycle +link. It is the only branch worth revisiting, but it is **not promoted**: the +declared parent gate required nonnegative core kills. No unrelated-family gate, +ZIP, upload, activation, or root replacement was performed. + +The downloaded production v9 archive is byte-identical to +`bots/meta-generalist-v1` across all four Python sources. The audited 95 ladder +games produced 43 wins and 52 losses; 49 losses were core destruction and 46 +of those had multi-turret home pressure. In the newest 45 games, 23 of 24 core +losses had multiple attackers and 23 were gunner-only. No enemy builder was +adjacent to the core when the first core shot landed in any of the 49 losses, +so builder-chasing and broad recall are misdiagnoses. At +round 40, mean served-harvester fraction was 79% in wins and 73% in losses. +Both titanium losses had incomplete networks; one contained a four-conveyor +cycle trapping a seven-link disconnected branch. Routing is real but secondary, +so neither rejected route variant should +be promoted over the exact active source. ## Current meta evidence @@ -122,6 +193,33 @@ Earlier measured negatives still stand: permanent guards, global hard recalls, full-ring sealing, attack-gate removal, launcher highways, from-scratch bastion, all-sieger allocation, and broad `attackBan` rewrites. +### Architecture-ceiling experiments + +The follow-up home-defense cycle is closed. These were deliberately small +mechanism probes; none is a promotion candidate. + +| Experiment | Key result | Decision | +|---|---|---| +| `exp_meta_multi_recall` | 47%/168, kills 64-78 | reject | +| `exp_meta_recall450` | 43%/168, kills 57-83 | reject | +| `exp_meta_safe_counter60` | 52% parent, 52% rush, 45% Oogbest | reject: opponent regression >5 pp | +| `exp_meta_damage_sequential` | 49%/168, kills 71-72 | reject | +| `exp_meta_damage_sequential400` | 49%/168, kills 70-69 | reject: misses parity | +| `exp_meta_counter_tend` | 51%/168 | reject: -739 mean Ti in round-1000 subset | +| `exp_meta_counter_tend6` | 47%/168, kills 63-78 | reject | +| `exp_meta_counter_tend8` | 52% parent; 51% rush | incomplete Oogbest gate; not promoted | +| `exp_dynamic_fronts` | 92/168 parent; 93/168 rush; 89/168 Oogbest | first consistent architectural gain, but only +1.8 to +3.6 pp; hold, do not promote | + +The repeated tradeoffs establish a chassis ceiling. `exp_dynamic_fronts` +replaces the single responder with up to three distinct pressure/coverage-sized +front assignments, preserves two local economy builders, latches both siegers +on offense, and bounds countergun maintenance to eight heals. It improved all +three tested families and core-kill margins, but the effect is still modest: +parent 92-76 (kills 74-62), rush 93-75 (82-64), Oogbest 89-79 (84-63). +Therefore fixed ownership was one real problem, not the whole problem. Stop +tuning recall HP, reserve thresholds, or heal counts; the next experiment must +also replace unbounded extra spawning and the universal round-40 gun churn. + ## Architecture and store protocol `exp_trans_40` / `meta-generalist-v1` are mostly memoryless state scorers with @@ -155,8 +253,8 @@ builders. ## Engine and measurement gotchas - Local WSL venv: `~/.venvs/fcode`; activate it before tests/gates. -- Current local CLI: `fcode 2.3.2.dev29` staging. Production has started; update - and sync production maps before trusting new gates. +- Current local CLI: production `fcode 2.3.3`; all 21 maps are present and the + remote session is authenticated. - Bot `random` is not seeded by engine `--seed`; identical serial games can flip. Use 168 for clear deltas and 336 for close ones. - Per-map gate rows are noisy and gate side labels are unreliable. Use aggregate @@ -190,17 +288,14 @@ Before any new remote action: install/authenticate the production CLI, run ## Next steps -1. Do not mutate `meta-generalist-v1`; branch experiments from it. -2. With explicit user approval only, authenticate production and upload the ZIP - as inactive. Do not activate. -3. Run multiple unranked series against diverse live opponents, not only Ijti. - Download every replay. Require nonnegative kills and evidence that enemy - delivery builders are actually being shot in more than one opponent family. -4. If production confirms the candidate, ask separately before root replacement - or activation. -5. Next local hypotheses should come from fresh production losses. Prefer source - correctness bugs or narrowly reactive observable-state fixes. Do not revisit - the rejected allocation, ammo, armor-rebuild, or breacher branches without - new multi-opponent evidence. +1. Keep root, exact v17 control, and the active submission unchanged. +2. Treat `exp_v17_gunner_control` as the only current promotion candidate. + Two independent parent blocks passed (104/168 and 96/168). Isolate its three + submechanisms only if further attribution is worth delaying packaging. +3. Test exact core-threatening-turret filtering for defender 4 independently; + do not stack it before it beats v17 and preserves the gun-control candidate. +4. Investigate the Sprint healed-stalemate and Sweden zero-damage replay + categories separately. Do not change map behavior by map name/dimensions. +5. No upload, queue, activation, or root replacement without fresh approval. No external action is authorized by this handoff. diff --git a/bots/exp_v17_gunner_control/README.md b/bots/exp_v17_gunner_control/README.md new file mode 100644 index 0000000..2fbd270 --- /dev/null +++ b/bots/exp_v17_gunner_control/README.md @@ -0,0 +1,19 @@ +# v17 gun-control experiment + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate only; it has not been packaged, uploaded, or activated. diff --git a/bots/exp_v17_gunner_control/main.py b/bots/exp_v17_gunner_control/main.py new file mode 100644 index 0000000..ee479e4 --- /dev/null +++ b/bots/exp_v17_gunner_control/main.py @@ -0,0 +1,520 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v17_gunner_control/mapPathfinding.py b/bots/exp_v17_gunner_control/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v17_gunner_control/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/live-v17-control/README.md b/bots/live-v17-control/README.md new file mode 100644 index 0000000..c10aae7 --- /dev/null +++ b/bots/live-v17-control/README.md @@ -0,0 +1,11 @@ +# Frozen production v17 control + +Downloaded from the active production submission `v17 (dumb bot v6)` on +2026-08-03. The original archive is preserved at +`fcode-gate-artifacts/ladder-v17-20260803/v17-live.zip` (SHA-256 +`4FB3457CBAB7AD77A0CF53E7CE37F8FD72DE8C90CDAD3F39F14F4126AA7C78F0`). + +The archive contained `_OogwayRush/main.py` and +`_OogwayRush/mapPathfinding.py`; those exact bytes are placed at this bot root +for local `fcode run` compatibility. Do not edit this control. + diff --git a/bots/live-v17-control/main.py b/bots/live-v17-control/main.py new file mode 100644 index 0000000..6562597 --- /dev/null +++ b/bots/live-v17-control/main.py @@ -0,0 +1,510 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + if targetId is not None and ct.get_team(targetId) != myTeam: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + bestScore = (0, 0, 0) + bestDir = myDir # so you only rotate when you need to + bestIsCoreDefense = False + for d in DIRECTIONS: + coreHits = 0 + gunnerHits = 0 + otherHits = 0 + curIsCoreDefense = False + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + curIsCoreDefense = True # this gunner is itself aimed at our core + else: + otherHits += 1 + curScore = (coreHits, gunnerHits, otherHits) + if curScore > bestScore: + bestScore = curScore + bestDir = d + bestIsCoreDefense = curIsCoreDefense + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + \ No newline at end of file diff --git a/bots/live-v17-control/mapPathfinding.py b/bots/live-v17-control/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/live-v17-control/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/live_replay_audit.py b/live_replay_audit.py index 1fd276b..022f18b 100644 --- a/live_replay_audit.py +++ b/live_replay_audit.py @@ -130,6 +130,7 @@ def audit_replay(path, metadata, team_id): core_shots_by_round = defaultdict(Counter) core_attack_sources = defaultdict(Counter) near_home_turret_builds = defaultdict(int) + near_home_turret_build_rounds = defaultdict(list) near_enemy_turret_builds = defaultdict(int) builder_deaths_before_core_damage = defaultdict(int) @@ -161,6 +162,7 @@ def current_entity_at(pos): if distance_sq_to_tiles(pos, core_tiles( entities[cores[team]]["pos"])) <= 36: near_home_turret_builds[team] += 1 + near_home_turret_build_rounds[team].append(round_index) if distance_sq_to_tiles(pos, core_tiles( entities[cores[1 - team]]["pos"])) <= 36: near_enemy_turret_builds[team] += 1 @@ -232,6 +234,13 @@ def current_entity_at(pos): key for key in core_attack_sources[our_team] if key[0] == enemy_team } + home_attacker_types = Counter(key[1] for key in home_attackers) + home_shots_by_type = Counter() + for key, count in core_attack_sources[our_team].items(): + if key[0] == enemy_team: + home_shots_by_type[key[1]] += count + first_home_round = first_core_shot.get(our_team) + our_home_build_rounds = near_home_turret_build_rounds[our_team] return { "match": match["id"][:8], "opponent": opponent, @@ -263,6 +272,8 @@ def current_entity_at(pos): "first_home_shot": first_core_shot.get(our_team), "first_enemy_core_shot": first_core_shot.get(enemy_team), "home_attacker_count": len(home_attackers), + "home_attacker_types": dict(home_attacker_types), + "home_shots_by_type": dict(home_shots_by_type), "our_core_attacker_count": len(our_attackers), "max_home_shots_round": max( core_shots_by_round[our_team].values(), default=0 @@ -271,6 +282,15 @@ def current_entity_at(pos): core_shots_by_round[enemy_team].values(), default=0 ), "our_near_home_turrets": near_home_turret_builds[our_team], + "our_home_turrets_before_first_shot": sum( + first_home_round is not None and round_number < first_home_round + for round_number in our_home_build_rounds + ), + "our_home_turrets_within_10_rounds": sum( + first_home_round is not None + and first_home_round <= round_number <= first_home_round + 10 + for round_number in our_home_build_rounds + ), "enemy_near_home_turrets": near_home_turret_builds[enemy_team], "our_forward_turrets": near_enemy_turret_builds[our_team], "enemy_forward_turrets": near_enemy_turret_builds[enemy_team], diff --git a/tests/test_v17_gunner_control.py b/tests/test_v17_gunner_control.py new file mode 100644 index 0000000..d218fdc --- /dev/null +++ b/tests/test_v17_gunner_control.py @@ -0,0 +1,116 @@ +"""Deterministic tests for the exact-v17 gun-control experiment.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v17_gunner_control" +PARENT = ROOT / "bots" / "live-v17-control" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v17_gunner_control", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class BuilderTargetController: + def __init__(self, friendly=False): + self.friendly = friendly + self.target = Position(0, 2) + self.fired = None + + def get_gunner_target(self): return self.target + def get_direction(self, entity_id=None): return Direction.NORTH + def get_position(self): return Position(0, 0) + def get_team(self, entity_id=None): + return 0 if entity_id is None or self.friendly else 1 + def get_tile_building_id(self, position): return None + def get_tile_builder_bot_id(self, position): return 7 + def can_fire(self, position): return position == self.target + def fire(self, position): self.fired = position + + +class AimController: + def __init__(self, current, attacks, resources=100): + self.current = current + self.attacks = attacks + self.resources = resources + self.rotated = None + self.entities = {} + next_id = 10 + for positions in attacks.values(): + for position in positions: + if position not in self.entities: + self.entities[position] = next_id + next_id += 1 + + def get_gunner_target(self): return None + def get_direction(self, entity_id=None): + return self.current if entity_id is None else Direction.SOUTH + def get_position(self): return Position(4, 4) + def get_team(self, entity_id=None): return 0 if entity_id is None else 1 + def get_attackable_tiles_from(self, position, direction, entity_type): + assert entity_type == EntityType.GUNNER + return self.attacks.get(direction, []) + def get_tile_building_id(self, position): return self.entities.get(position) + def get_entity_type(self, entity_id): return EntityType.GUNNER + def get_global_resources(self): return self.resources + def can_rotate(self, direction): return True + def rotate(self, direction): self.rotated = direction + + +def test_gunner_fires_lone_enemy_builder_but_not_friendly_builder(): + enemy = BuilderTargetController() + BOT.Player().runGunner(enemy) + assert enemy.fired == enemy.target + friendly = BuilderTargetController(friendly=True) + BOT.Player().runGunner(friendly) + assert friendly.fired is None + + +def test_equal_score_keeps_current_facing(): + north = Position(4, 1) + east = Position(7, 4) + ct = AimController( + Direction.EAST, + {Direction.NORTH: [north], Direction.EAST: [east]}) + player = BOT.Player() + player.coreThreatSpots = lambda _: set() + player.runGunner(ct) + assert ct.rotated is None + + +def test_core_threat_target_beats_multiple_unrelated_guns(): + threat = Position(4, 1) + ct = AimController( + Direction.EAST, + { + Direction.NORTH: [threat], + Direction.EAST: [Position(7, 4), Position(6, 4)], + }, + resources=30, + ) + player = BOT.Player() + player.coreThreatSpots = lambda _: { + (threat.x, threat.y, Direction.SOUTH) + } + player.runGunner(ct) + assert ct.rotated == Direction.NORTH + + +def test_scope_and_no_fingerprints(): + assert (BOT_DIR / "mapPathfinding.py").read_bytes() == ( + PARENT / "mapPathfinding.py").read_bytes() + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "pinch.map", "pantheon", "smartfridge"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_gunner_fires_lone_enemy_builder_but_not_friendly_builder() + test_equal_score_keeps_current_facing() + test_core_threat_target_beats_multiple_unrelated_guns() + test_scope_and_no_fingerprints() From 8655263d559efa886dcc212a6f516a1835b76cba Mon Sep 17 00:00:00 2001 From: Subodh Date: Mon, 3 Aug 2026 09:44:59 +0200 Subject: [PATCH 17/30] record v18 unranked results --- HANDOFF.md | 18 ++++++++++++++++-- bots/exp_v17_gunner_control/README.md | 5 ++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 02cc5e4..1a43a0d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -99,7 +99,20 @@ floor, not targeting priority. `exp_v17_gunner_control` fixes exactly these. | vs `meta-generalist-v1` | **144/168 (86%)** | 142/168 (85%) | **142-19** vs 140-25 | The candidate passes deterministic targeting/tie/priority tests, compiles, and -completed a smoke game. It has not been packaged, uploaded, or activated. +completed a smoke game. It was packaged byte-identically as submission v18 +`v17 gun-control candidate` (ZIP SHA-256 +`416FAB58A9EBA8F22735EAC0A5E1339617F125E5AD9B7CD26174CEDDB5B77359`). +The CLI automatically activated v18 on upload; v17 was immediately restored as +active. Treat upload as activation-capable in future workflows. + +Five v18 unranked series pinned to the opponent versions from the loss sample +scored **22/25**: Pantheon 5-0, Askar City 5-0, team lazy 3-2, +CtrlAltDefeat 4-1, and the one piece 5-0. Replays are in +`C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v17-20260803/v18-unranked/`. +All three losses were multi-gunner core destructions: team lazy on Showdown and +Runestone, plus a turn-990 Sweden loss to CtrlAltDefeat where 200 core damage +was fully healed. This confirms early races and healed/stalled Sweden remain +the live failure categories. ## Conveyor iteration on production engine 2.3.3 @@ -296,6 +309,7 @@ Before any new remote action: install/authenticate the production CLI, run do not stack it before it beats v17 and preserves the gun-control candidate. 4. Investigate the Sprint healed-stalemate and Sweden zero-damage replay categories separately. Do not change map behavior by map name/dimensions. -5. No upload, queue, activation, or root replacement without fresh approval. +5. v18 is uploaded but inactive; v17 remains ladder-active. No activation or + root replacement without fresh approval. No external action is authorized by this handoff. diff --git a/bots/exp_v17_gunner_control/README.md b/bots/exp_v17_gunner_control/README.md index 2fbd270..91e4615 100644 --- a/bots/exp_v17_gunner_control/README.md +++ b/bots/exp_v17_gunner_control/README.md @@ -16,4 +16,7 @@ Fresh production-engine gates (21 maps, four seeds, both sides): | vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local -candidate only; it has not been packaged, uploaded, or activated. +candidate packaged as submission v18 (`v17 gun-control candidate`). Five +unranked series against replay-loss opponents scored 22/25: Pantheon 5-0, +Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one piece 5-0. +Production v17 was restored as the active ladder submission after queueing. From a202c6689193aa103f22e7ba2675bb7d2e9e7469 Mon Sep 17 00:00:00 2001 From: Subodh Date: Mon, 3 Aug 2026 09:58:37 +0200 Subject: [PATCH 18/30] record v18 activation and repeat scrims --- HANDOFF.md | 19 ++++++++++--------- bots/exp_v17_gunner_control/README.md | 9 +++++---- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 1a43a0d..2924df0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -105,14 +105,15 @@ completed a smoke game. It was packaged byte-identically as submission v18 The CLI automatically activated v18 on upload; v17 was immediately restored as active. Treat upload as activation-capable in future workflows. -Five v18 unranked series pinned to the opponent versions from the loss sample -scored **22/25**: Pantheon 5-0, Askar City 5-0, team lazy 3-2, -CtrlAltDefeat 4-1, and the one piece 5-0. Replays are in +Eight v18 unranked series pinned to opponent versions from the loss sample +scored **34/40**. Batch one: Pantheon 5-0, Askar City 5-0, team lazy 3-2, +CtrlAltDefeat 4-1, and the one piece 5-0. Batch two: SmartFridge 3-2, +team lazy 4-1, and CtrlAltDefeat 5-0. Replays are in `C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v17-20260803/v18-unranked/`. -All three losses were multi-gunner core destructions: team lazy on Showdown and -Runestone, plus a turn-990 Sweden loss to CtrlAltDefeat where 200 core damage -was fully healed. This confirms early races and healed/stalled Sweden remain -the live failure categories. +and its `v18-unranked-repeat/` sibling. All six losses were multi-gunner core +destructions. Five were early races on Showdown, Runestone, Sprint, or Atoll; +one was a turn-990 Sweden loss where 200 damage was fully healed. This confirms +early gun races and healed/stalled Sweden remain the live failure categories. ## Conveyor iteration on production engine 2.3.3 @@ -309,7 +310,7 @@ Before any new remote action: install/authenticate the production CLI, run do not stack it before it beats v17 and preserves the gun-control candidate. 4. Investigate the Sprint healed-stalemate and Sweden zero-damage replay categories separately. Do not change map behavior by map name/dimensions. -5. v18 is uploaded but inactive; v17 remains ladder-active. No activation or - root replacement without fresh approval. +5. The user explicitly promoted v18 on 2026-08-03; it is ladder-active. Root + remains unchanged and still requires separate approval. No external action is authorized by this handoff. diff --git a/bots/exp_v17_gunner_control/README.md b/bots/exp_v17_gunner_control/README.md index 91e4615..b4a832a 100644 --- a/bots/exp_v17_gunner_control/README.md +++ b/bots/exp_v17_gunner_control/README.md @@ -16,7 +16,8 @@ Fresh production-engine gates (21 maps, four seeds, both sides): | vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local -candidate packaged as submission v18 (`v17 gun-control candidate`). Five -unranked series against replay-loss opponents scored 22/25: Pantheon 5-0, -Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one piece 5-0. -Production v17 was restored as the active ladder submission after queueing. +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. From 9f3deb29de44de74809a88baaa7fed297d11b53b Mon Sep 17 00:00:00 2001 From: Subodh Date: Mon, 3 Aug 2026 17:33:32 +0200 Subject: [PATCH 19/30] normalize line endings after full push --- HANDOFF.md | 239 ++- artifacts/submissions/v19-route-proof.zip | Bin 0 -> 13849 bytes .../submissions/v20-near-core-finish.zip | Bin 0 -> 14600 bytes .../v21-opportunistic-trunk-repair.zip | Bin 0 -> 15526 bytes artifacts/submissions/v21-server.zip | Bin 0 -> 15848 bytes .../v22-damaged-core-eco-release.zip | Bin 0 -> 15705 bytes artifacts/submissions/v22-server.zip | Bin 0 -> 16039 bytes bots/exp_dynamic_fronts/README.md | 23 + bots/exp_dynamic_fronts/initialSpawning.py | 120 ++ bots/exp_dynamic_fronts/main.py | 1341 +++++++++++++ bots/exp_dynamic_fronts/mapPathfinding.py | 392 ++++ bots/exp_dynamic_fronts/symmetry.py | 116 ++ bots/exp_late_route_idle/README.md | 25 + bots/exp_late_route_idle/initialSpawning.py | 120 ++ bots/exp_late_route_idle/main.py | 1345 +++++++++++++ bots/exp_late_route_idle/mapPathfinding.py | 643 +++++++ bots/exp_late_route_idle/symmetry.py | 116 ++ bots/exp_late_route_repair/README.md | 7 + bots/exp_meta_counter_tend/README.md | 19 + bots/exp_meta_counter_tend/initialSpawning.py | 120 ++ bots/exp_meta_counter_tend/main.py | 1267 +++++++++++++ bots/exp_meta_counter_tend/mapPathfinding.py | 392 ++++ bots/exp_meta_counter_tend/symmetry.py | 116 ++ bots/exp_meta_counter_tend6/README.md | 19 + .../exp_meta_counter_tend6/initialSpawning.py | 120 ++ bots/exp_meta_counter_tend6/main.py | 1271 +++++++++++++ bots/exp_meta_counter_tend6/mapPathfinding.py | 392 ++++ bots/exp_meta_counter_tend6/symmetry.py | 116 ++ bots/exp_meta_counter_tend8/README.md | 19 + .../exp_meta_counter_tend8/initialSpawning.py | 120 ++ bots/exp_meta_counter_tend8/main.py | 1271 +++++++++++++ bots/exp_meta_counter_tend8/mapPathfinding.py | 392 ++++ bots/exp_meta_counter_tend8/symmetry.py | 116 ++ bots/exp_meta_damage_sequential/README.md | 19 + .../initialSpawning.py | 120 ++ bots/exp_meta_damage_sequential/main.py | 1273 +++++++++++++ .../mapPathfinding.py | 392 ++++ bots/exp_meta_damage_sequential/symmetry.py | 116 ++ bots/exp_meta_damage_sequential400/README.md | 19 + .../initialSpawning.py | 120 ++ bots/exp_meta_damage_sequential400/main.py | 1273 +++++++++++++ .../mapPathfinding.py | 392 ++++ .../exp_meta_damage_sequential400/symmetry.py | 116 ++ bots/exp_meta_multi_recall/README.md | 17 + bots/exp_meta_multi_recall/initialSpawning.py | 120 ++ bots/exp_meta_multi_recall/main.py | 1258 ++++++++++++ bots/exp_meta_multi_recall/mapPathfinding.py | 392 ++++ bots/exp_meta_multi_recall/symmetry.py | 116 ++ bots/exp_meta_recall450/README.md | 19 + bots/exp_meta_recall450/initialSpawning.py | 120 ++ bots/exp_meta_recall450/main.py | 1254 ++++++++++++ bots/exp_meta_recall450/mapPathfinding.py | 392 ++++ bots/exp_meta_recall450/symmetry.py | 116 ++ bots/exp_meta_role2_recall/README.md | 19 + bots/exp_meta_role2_recall/initialSpawning.py | 120 ++ bots/exp_meta_role2_recall/main.py | 1271 +++++++++++++ bots/exp_meta_role2_recall/mapPathfinding.py | 392 ++++ bots/exp_meta_role2_recall/symmetry.py | 116 ++ bots/exp_meta_safe_counter60/README.md | 19 + .../initialSpawning.py | 120 ++ bots/exp_meta_safe_counter60/main.py | 1255 ++++++++++++ .../exp_meta_safe_counter60/mapPathfinding.py | 392 ++++ bots/exp_meta_safe_counter60/symmetry.py | 116 ++ bots/exp_meta_sequential_responder/README.md | 17 + .../initialSpawning.py | 120 ++ bots/exp_meta_sequential_responder/main.py | 1268 +++++++++++++ .../mapPathfinding.py | 392 ++++ .../exp_meta_sequential_responder/symmetry.py | 116 ++ bots/exp_meta_single_countertrade/README.md | 17 + .../initialSpawning.py | 120 ++ bots/exp_meta_single_countertrade/main.py | 1252 ++++++++++++ .../mapPathfinding.py | 392 ++++ bots/exp_meta_single_countertrade/symmetry.py | 116 ++ bots/exp_route_integrity/README.md | 16 + bots/exp_route_integrity/initialSpawning.py | 120 ++ bots/exp_route_integrity/main.py | 1345 +++++++++++++ bots/exp_route_integrity/mapPathfinding.py | 702 +++++++ bots/exp_route_integrity/symmetry.py | 116 ++ bots/exp_stalemate_route_repair/README.md | 27 + .../initialSpawning.py | 120 ++ bots/exp_stalemate_route_repair/main.py | 1345 +++++++++++++ .../mapPathfinding.py | 643 +++++++ bots/exp_stalemate_route_repair/symmetry.py | 116 ++ bots/exp_v18_build_stands/README.md | 28 + bots/exp_v18_build_stands/main.py | 541 ++++++ bots/exp_v18_build_stands/mapPathfinding.py | 670 +++++++ bots/exp_v18_core_threat_filter/README.md | 27 + bots/exp_v18_core_threat_filter/main.py | 541 ++++++ .../mapPathfinding.py | 670 +++++++ bots/exp_v18_harvest_stand/README.md | 27 + bots/exp_v18_harvest_stand/main.py | 531 ++++++ bots/exp_v18_harvest_stand/mapPathfinding.py | 670 +++++++ bots/exp_v18_heal_stand/README.md | 27 + bots/exp_v18_heal_stand/main.py | 530 ++++++ bots/exp_v18_heal_stand/mapPathfinding.py | 670 +++++++ bots/exp_v18_line_integrity/README.md | 28 + bots/exp_v18_line_integrity/main.py | 543 ++++++ bots/exp_v18_line_integrity/mapPathfinding.py | 670 +++++++ bots/exp_v18_siege_stand/README.md | 27 + bots/exp_v18_siege_stand/main.py | 533 ++++++ bots/exp_v18_siege_stand/mapPathfinding.py | 670 +++++++ bots/exp_v18_spawn_discipline/README.md | 21 + bots/exp_v18_spawn_discipline/main.py | 534 ++++++ .../mapPathfinding.py | 670 +++++++ bots/exp_v18_spawn_immediate_seat/README.md | 22 + bots/exp_v18_spawn_immediate_seat/main.py | 545 ++++++ .../mapPathfinding.py | 670 +++++++ bots/exp_v18_spawn_passive_heal/README.md | 22 + bots/exp_v18_spawn_passive_heal/main.py | 586 ++++++ .../mapPathfinding.py | 670 +++++++ bots/exp_v18_spawn_route_proof/README.md | 24 + bots/exp_v18_spawn_route_proof/main.py | 534 ++++++ .../mapPathfinding.py | 726 +++++++ .../exp_v18_spawn_route_proof_guard/README.md | 27 + bots/exp_v18_spawn_route_proof_guard/main.py | 538 ++++++ .../mapPathfinding.py | 726 +++++++ .../README.md | 27 + .../main.py | 538 ++++++ .../mapPathfinding.py | 726 +++++++ bots/exp_v19_near_core_finish/README.md | 31 + bots/exp_v19_near_core_finish/main.py | 624 ++++++ .../mapPathfinding.py | 726 +++++++ bots/exp_v19_sticky_route/README.md | 30 + bots/exp_v19_sticky_route/main.py | 609 ++++++ bots/exp_v19_sticky_route/mapPathfinding.py | 726 +++++++ bots/exp_v20_gunner_preempt/README.md | 31 + bots/exp_v20_gunner_preempt/main.py | 630 +++++++ bots/exp_v20_gunner_preempt/mapPathfinding.py | 726 +++++++ bots/exp_v20_opportunistic_pressure/README.md | 37 + bots/exp_v20_opportunistic_pressure/main.py | 661 +++++++ .../mapPathfinding.py | 726 +++++++ .../README.md | 32 + .../main.py | 708 +++++++ .../mapPathfinding.py | 726 +++++++ bots/exp_v20_trunk_repair/README.md | 31 + bots/exp_v20_trunk_repair/main.py | 813 ++++++++ bots/exp_v20_trunk_repair/mapPathfinding.py | 726 +++++++ bots/exp_v20_trunk_repair_late/README.md | 31 + bots/exp_v20_trunk_repair_late/main.py | 816 ++++++++ .../mapPathfinding.py | 726 +++++++ bots/exp_v21_eco_release/README.md | 32 + bots/exp_v21_eco_release/main.py | 710 +++++++ bots/exp_v21_eco_release/mapPathfinding.py | 726 +++++++ .../README.md | 42 + bots/exp_v21_eco_release_only_damaged/main.py | 717 +++++++ .../mapPathfinding.py | 726 +++++++ bots/exp_waller_route_light/README.md | 27 + .../exp_waller_route_light/initialSpawning.py | 120 ++ bots/exp_waller_route_light/main.py | 1345 +++++++++++++ bots/exp_waller_route_light/mapPathfinding.py | 647 +++++++ bots/exp_waller_route_light/symmetry.py | 116 ++ bots/exp_waller_route_repair/README.md | 29 + .../initialSpawning.py | 120 ++ bots/exp_waller_route_repair/main.py | 1342 +++++++++++++ .../exp_waller_route_repair/mapPathfinding.py | 643 +++++++ bots/exp_waller_route_repair/symmetry.py | 116 ++ bots/exp_waller_route_reserve/README.md | 15 + .../initialSpawning.py | 120 ++ bots/exp_waller_route_reserve/main.py | 1345 +++++++++++++ .../mapPathfinding.py | 647 +++++++ bots/exp_waller_route_reserve/symmetry.py | 116 ++ conveyor_audit.py | 26 + replay.replay26 | Bin 170049 -> 62161 bytes tests/test_conveyor_audit.py | 55 + tests/test_dynamic_fronts.py | 78 + tests/test_late_route_idle.py | 297 +++ tests/test_meta_counter_tend.py | 99 + tests/test_meta_counter_tend6.py | 65 + tests/test_meta_counter_tend8.py | 53 + tests/test_meta_damage_sequential.py | 79 + tests/test_meta_damage_sequential400.py | 69 + tests/test_meta_multi_recall.py | 63 + tests/test_meta_recall450.py | 55 + tests/test_meta_role2_recall.py | 65 + tests/test_meta_safe_counter60.py | 54 + tests/test_meta_sequential_responder.py | 72 + tests/test_meta_single_countertrade.py | 118 ++ tests/test_route_integrity.py | 90 + tests/test_stalemate_route_repair.py | 300 +++ tests/test_v18_build_stands.py | 55 + tests/test_v18_core_threat_filter.py | 39 + tests/test_v18_harvest_stand.py | 38 + tests/test_v18_heal_stand.py | 27 + tests/test_v18_line_integrity.py | 112 ++ tests/test_v18_siege_stand.py | 34 + tests/test_v18_spawn_discipline.py | 30 + tests/test_v18_spawn_immediate_seat.py | 58 + tests/test_v18_spawn_passive_heal.py | 61 + tests/test_v18_spawn_route_proof.py | 57 + tests/test_v18_spawn_route_proof_guard.py | 43 + .../test_v18_spawn_route_proof_vision_skip.py | 44 + tests/test_v19_near_core_finish.py | 72 + tests/test_v19_sticky_route.py | 69 + tests/test_v20_gunner_preempt.py | 87 + tests/test_v20_opportunistic_pressure.py | 29 + tests/test_v20_opportunistic_trunk_repair.py | 111 ++ tests/test_v20_trunk_repair.py | 111 ++ tests/test_v20_trunk_repair_late.py | 126 ++ tests/test_v21_eco_release.py | 77 + tests/test_v21_eco_release_only_damaged.py | 84 + tests/test_waller_route_light.py | 319 ++++ tests/test_waller_route_repair.py | 308 +++ tests/test_waller_route_reserve.py | 48 + .../pantheon_postmortem/contact-01-04.jpg | Bin 0 -> 888111 bytes .../pantheon_postmortem/contact-05-08.jpg | Bin 0 -> 1057697 bytes .../pantheon_postmortem/contact-09-12.jpg | Bin 0 -> 991719 bytes .../pantheon_postmortem/contact-13-16.jpg | Bin 0 -> 966714 bytes .../pantheon_postmortem/contact-17-20.jpg | Bin 0 -> 997962 bytes .../pantheon_postmortem/contact-21-24.jpg | Bin 0 -> 903608 bytes tmp/pdfs/pantheon_postmortem/page-01.png | Bin 0 -> 440360 bytes tmp/pdfs/pantheon_postmortem/page-02.png | Bin 0 -> 412257 bytes tmp/pdfs/pantheon_postmortem/page-03.png | Bin 0 -> 615409 bytes tmp/pdfs/pantheon_postmortem/page-04.png | Bin 0 -> 708135 bytes tmp/pdfs/pantheon_postmortem/page-05.png | Bin 0 -> 511685 bytes tmp/pdfs/pantheon_postmortem/page-06.png | Bin 0 -> 382935 bytes tmp/pdfs/pantheon_postmortem/page-07.png | Bin 0 -> 434026 bytes tmp/pdfs/pantheon_postmortem/page-08.png | Bin 0 -> 450366 bytes tmp/pdfs/pantheon_postmortem/page-09.png | Bin 0 -> 404154 bytes tmp/pdfs/pantheon_postmortem/page-10.png | Bin 0 -> 384908 bytes tmp/pdfs/pantheon_postmortem/page-11.png | Bin 0 -> 521621 bytes tmp/pdfs/pantheon_postmortem/page-12.png | Bin 0 -> 377367 bytes tmp/pdfs/pantheon_postmortem/page-13.png | Bin 0 -> 458704 bytes tmp/pdfs/pantheon_postmortem/page-14.png | Bin 0 -> 566915 bytes tmp/pdfs/pantheon_postmortem/page-15.png | Bin 0 -> 432385 bytes tmp/pdfs/pantheon_postmortem/page-16.png | Bin 0 -> 431716 bytes tmp/pdfs/pantheon_postmortem/page-17.png | Bin 0 -> 439359 bytes tmp/pdfs/pantheon_postmortem/page-18.png | Bin 0 -> 389176 bytes tmp/pdfs/pantheon_postmortem/page-19.png | Bin 0 -> 435836 bytes tmp/pdfs/pantheon_postmortem/page-20.png | Bin 0 -> 375659 bytes tmp/pdfs/pantheon_postmortem/page-21.png | Bin 0 -> 417433 bytes tmp/pdfs/pantheon_postmortem/page-22.png | Bin 0 -> 472057 bytes tmp/pdfs/pantheon_postmortem/page-23.png | Bin 0 -> 341992 bytes tmp/pdfs/pantheon_postmortem/page-24.png | Bin 0 -> 306428 bytes .../pantheon_postmortem/postmortem-flow.txt | 1418 ++++++++++++++ tmp/pdfs/pantheon_postmortem/postmortem.txt | 1679 +++++++++++++++++ waller_route_light_results.json | 25 + 236 files changed, 71718 insertions(+), 18 deletions(-) create mode 100644 artifacts/submissions/v19-route-proof.zip create mode 100644 artifacts/submissions/v20-near-core-finish.zip create mode 100644 artifacts/submissions/v21-opportunistic-trunk-repair.zip create mode 100644 artifacts/submissions/v21-server.zip create mode 100644 artifacts/submissions/v22-damaged-core-eco-release.zip create mode 100644 artifacts/submissions/v22-server.zip create mode 100644 bots/exp_dynamic_fronts/README.md create mode 100644 bots/exp_dynamic_fronts/initialSpawning.py create mode 100644 bots/exp_dynamic_fronts/main.py create mode 100644 bots/exp_dynamic_fronts/mapPathfinding.py create mode 100644 bots/exp_dynamic_fronts/symmetry.py create mode 100644 bots/exp_late_route_idle/README.md create mode 100644 bots/exp_late_route_idle/initialSpawning.py create mode 100644 bots/exp_late_route_idle/main.py create mode 100644 bots/exp_late_route_idle/mapPathfinding.py create mode 100644 bots/exp_late_route_idle/symmetry.py create mode 100644 bots/exp_meta_counter_tend/README.md create mode 100644 bots/exp_meta_counter_tend/initialSpawning.py create mode 100644 bots/exp_meta_counter_tend/main.py create mode 100644 bots/exp_meta_counter_tend/mapPathfinding.py create mode 100644 bots/exp_meta_counter_tend/symmetry.py create mode 100644 bots/exp_meta_counter_tend6/README.md create mode 100644 bots/exp_meta_counter_tend6/initialSpawning.py create mode 100644 bots/exp_meta_counter_tend6/main.py create mode 100644 bots/exp_meta_counter_tend6/mapPathfinding.py create mode 100644 bots/exp_meta_counter_tend6/symmetry.py create mode 100644 bots/exp_meta_counter_tend8/README.md create mode 100644 bots/exp_meta_counter_tend8/initialSpawning.py create mode 100644 bots/exp_meta_counter_tend8/main.py create mode 100644 bots/exp_meta_counter_tend8/mapPathfinding.py create mode 100644 bots/exp_meta_counter_tend8/symmetry.py create mode 100644 bots/exp_meta_damage_sequential/README.md create mode 100644 bots/exp_meta_damage_sequential/initialSpawning.py create mode 100644 bots/exp_meta_damage_sequential/main.py create mode 100644 bots/exp_meta_damage_sequential/mapPathfinding.py create mode 100644 bots/exp_meta_damage_sequential/symmetry.py create mode 100644 bots/exp_meta_damage_sequential400/README.md create mode 100644 bots/exp_meta_damage_sequential400/initialSpawning.py create mode 100644 bots/exp_meta_damage_sequential400/main.py create mode 100644 bots/exp_meta_damage_sequential400/mapPathfinding.py create mode 100644 bots/exp_meta_damage_sequential400/symmetry.py create mode 100644 bots/exp_meta_multi_recall/README.md create mode 100644 bots/exp_meta_multi_recall/initialSpawning.py create mode 100644 bots/exp_meta_multi_recall/main.py create mode 100644 bots/exp_meta_multi_recall/mapPathfinding.py create mode 100644 bots/exp_meta_multi_recall/symmetry.py create mode 100644 bots/exp_meta_recall450/README.md create mode 100644 bots/exp_meta_recall450/initialSpawning.py create mode 100644 bots/exp_meta_recall450/main.py create mode 100644 bots/exp_meta_recall450/mapPathfinding.py create mode 100644 bots/exp_meta_recall450/symmetry.py create mode 100644 bots/exp_meta_role2_recall/README.md create mode 100644 bots/exp_meta_role2_recall/initialSpawning.py create mode 100644 bots/exp_meta_role2_recall/main.py create mode 100644 bots/exp_meta_role2_recall/mapPathfinding.py create mode 100644 bots/exp_meta_role2_recall/symmetry.py create mode 100644 bots/exp_meta_safe_counter60/README.md create mode 100644 bots/exp_meta_safe_counter60/initialSpawning.py create mode 100644 bots/exp_meta_safe_counter60/main.py create mode 100644 bots/exp_meta_safe_counter60/mapPathfinding.py create mode 100644 bots/exp_meta_safe_counter60/symmetry.py create mode 100644 bots/exp_meta_sequential_responder/README.md create mode 100644 bots/exp_meta_sequential_responder/initialSpawning.py create mode 100644 bots/exp_meta_sequential_responder/main.py create mode 100644 bots/exp_meta_sequential_responder/mapPathfinding.py create mode 100644 bots/exp_meta_sequential_responder/symmetry.py create mode 100644 bots/exp_meta_single_countertrade/README.md create mode 100644 bots/exp_meta_single_countertrade/initialSpawning.py create mode 100644 bots/exp_meta_single_countertrade/main.py create mode 100644 bots/exp_meta_single_countertrade/mapPathfinding.py create mode 100644 bots/exp_meta_single_countertrade/symmetry.py create mode 100644 bots/exp_route_integrity/README.md create mode 100644 bots/exp_route_integrity/initialSpawning.py create mode 100644 bots/exp_route_integrity/main.py create mode 100644 bots/exp_route_integrity/mapPathfinding.py create mode 100644 bots/exp_route_integrity/symmetry.py create mode 100644 bots/exp_stalemate_route_repair/README.md create mode 100644 bots/exp_stalemate_route_repair/initialSpawning.py create mode 100644 bots/exp_stalemate_route_repair/main.py create mode 100644 bots/exp_stalemate_route_repair/mapPathfinding.py create mode 100644 bots/exp_stalemate_route_repair/symmetry.py create mode 100644 bots/exp_v18_build_stands/README.md create mode 100644 bots/exp_v18_build_stands/main.py create mode 100644 bots/exp_v18_build_stands/mapPathfinding.py create mode 100644 bots/exp_v18_core_threat_filter/README.md create mode 100644 bots/exp_v18_core_threat_filter/main.py create mode 100644 bots/exp_v18_core_threat_filter/mapPathfinding.py create mode 100644 bots/exp_v18_harvest_stand/README.md create mode 100644 bots/exp_v18_harvest_stand/main.py create mode 100644 bots/exp_v18_harvest_stand/mapPathfinding.py create mode 100644 bots/exp_v18_heal_stand/README.md create mode 100644 bots/exp_v18_heal_stand/main.py create mode 100644 bots/exp_v18_heal_stand/mapPathfinding.py create mode 100644 bots/exp_v18_line_integrity/README.md create mode 100644 bots/exp_v18_line_integrity/main.py create mode 100644 bots/exp_v18_line_integrity/mapPathfinding.py create mode 100644 bots/exp_v18_siege_stand/README.md create mode 100644 bots/exp_v18_siege_stand/main.py create mode 100644 bots/exp_v18_siege_stand/mapPathfinding.py create mode 100644 bots/exp_v18_spawn_discipline/README.md create mode 100644 bots/exp_v18_spawn_discipline/main.py create mode 100644 bots/exp_v18_spawn_discipline/mapPathfinding.py create mode 100644 bots/exp_v18_spawn_immediate_seat/README.md create mode 100644 bots/exp_v18_spawn_immediate_seat/main.py create mode 100644 bots/exp_v18_spawn_immediate_seat/mapPathfinding.py create mode 100644 bots/exp_v18_spawn_passive_heal/README.md create mode 100644 bots/exp_v18_spawn_passive_heal/main.py create mode 100644 bots/exp_v18_spawn_passive_heal/mapPathfinding.py create mode 100644 bots/exp_v18_spawn_route_proof/README.md create mode 100644 bots/exp_v18_spawn_route_proof/main.py create mode 100644 bots/exp_v18_spawn_route_proof/mapPathfinding.py create mode 100644 bots/exp_v18_spawn_route_proof_guard/README.md create mode 100644 bots/exp_v18_spawn_route_proof_guard/main.py create mode 100644 bots/exp_v18_spawn_route_proof_guard/mapPathfinding.py create mode 100644 bots/exp_v18_spawn_route_proof_vision_skip/README.md create mode 100644 bots/exp_v18_spawn_route_proof_vision_skip/main.py create mode 100644 bots/exp_v18_spawn_route_proof_vision_skip/mapPathfinding.py create mode 100644 bots/exp_v19_near_core_finish/README.md create mode 100644 bots/exp_v19_near_core_finish/main.py create mode 100644 bots/exp_v19_near_core_finish/mapPathfinding.py create mode 100644 bots/exp_v19_sticky_route/README.md create mode 100644 bots/exp_v19_sticky_route/main.py create mode 100644 bots/exp_v19_sticky_route/mapPathfinding.py create mode 100644 bots/exp_v20_gunner_preempt/README.md create mode 100644 bots/exp_v20_gunner_preempt/main.py create mode 100644 bots/exp_v20_gunner_preempt/mapPathfinding.py create mode 100644 bots/exp_v20_opportunistic_pressure/README.md create mode 100644 bots/exp_v20_opportunistic_pressure/main.py create mode 100644 bots/exp_v20_opportunistic_pressure/mapPathfinding.py create mode 100644 bots/exp_v20_opportunistic_trunk_repair/README.md create mode 100644 bots/exp_v20_opportunistic_trunk_repair/main.py create mode 100644 bots/exp_v20_opportunistic_trunk_repair/mapPathfinding.py create mode 100644 bots/exp_v20_trunk_repair/README.md create mode 100644 bots/exp_v20_trunk_repair/main.py create mode 100644 bots/exp_v20_trunk_repair/mapPathfinding.py create mode 100644 bots/exp_v20_trunk_repair_late/README.md create mode 100644 bots/exp_v20_trunk_repair_late/main.py create mode 100644 bots/exp_v20_trunk_repair_late/mapPathfinding.py create mode 100644 bots/exp_v21_eco_release/README.md create mode 100644 bots/exp_v21_eco_release/main.py create mode 100644 bots/exp_v21_eco_release/mapPathfinding.py create mode 100644 bots/exp_v21_eco_release_only_damaged/README.md create mode 100644 bots/exp_v21_eco_release_only_damaged/main.py create mode 100644 bots/exp_v21_eco_release_only_damaged/mapPathfinding.py create mode 100644 bots/exp_waller_route_light/README.md create mode 100644 bots/exp_waller_route_light/initialSpawning.py create mode 100644 bots/exp_waller_route_light/main.py create mode 100644 bots/exp_waller_route_light/mapPathfinding.py create mode 100644 bots/exp_waller_route_light/symmetry.py create mode 100644 bots/exp_waller_route_repair/README.md create mode 100644 bots/exp_waller_route_repair/initialSpawning.py create mode 100644 bots/exp_waller_route_repair/main.py create mode 100644 bots/exp_waller_route_repair/mapPathfinding.py create mode 100644 bots/exp_waller_route_repair/symmetry.py create mode 100644 bots/exp_waller_route_reserve/README.md create mode 100644 bots/exp_waller_route_reserve/initialSpawning.py create mode 100644 bots/exp_waller_route_reserve/main.py create mode 100644 bots/exp_waller_route_reserve/mapPathfinding.py create mode 100644 bots/exp_waller_route_reserve/symmetry.py create mode 100644 tests/test_conveyor_audit.py create mode 100644 tests/test_dynamic_fronts.py create mode 100644 tests/test_late_route_idle.py create mode 100644 tests/test_meta_counter_tend.py create mode 100644 tests/test_meta_counter_tend6.py create mode 100644 tests/test_meta_counter_tend8.py create mode 100644 tests/test_meta_damage_sequential.py create mode 100644 tests/test_meta_damage_sequential400.py create mode 100644 tests/test_meta_multi_recall.py create mode 100644 tests/test_meta_recall450.py create mode 100644 tests/test_meta_role2_recall.py create mode 100644 tests/test_meta_safe_counter60.py create mode 100644 tests/test_meta_sequential_responder.py create mode 100644 tests/test_meta_single_countertrade.py create mode 100644 tests/test_route_integrity.py create mode 100644 tests/test_stalemate_route_repair.py create mode 100644 tests/test_v18_build_stands.py create mode 100644 tests/test_v18_core_threat_filter.py create mode 100644 tests/test_v18_harvest_stand.py create mode 100644 tests/test_v18_heal_stand.py create mode 100644 tests/test_v18_line_integrity.py create mode 100644 tests/test_v18_siege_stand.py create mode 100644 tests/test_v18_spawn_discipline.py create mode 100644 tests/test_v18_spawn_immediate_seat.py create mode 100644 tests/test_v18_spawn_passive_heal.py create mode 100644 tests/test_v18_spawn_route_proof.py create mode 100644 tests/test_v18_spawn_route_proof_guard.py create mode 100644 tests/test_v18_spawn_route_proof_vision_skip.py create mode 100644 tests/test_v19_near_core_finish.py create mode 100644 tests/test_v19_sticky_route.py create mode 100644 tests/test_v20_gunner_preempt.py create mode 100644 tests/test_v20_opportunistic_pressure.py create mode 100644 tests/test_v20_opportunistic_trunk_repair.py create mode 100644 tests/test_v20_trunk_repair.py create mode 100644 tests/test_v20_trunk_repair_late.py create mode 100644 tests/test_v21_eco_release.py create mode 100644 tests/test_v21_eco_release_only_damaged.py create mode 100644 tests/test_waller_route_light.py create mode 100644 tests/test_waller_route_repair.py create mode 100644 tests/test_waller_route_reserve.py create mode 100644 tmp/pdfs/pantheon_postmortem/contact-01-04.jpg create mode 100644 tmp/pdfs/pantheon_postmortem/contact-05-08.jpg create mode 100644 tmp/pdfs/pantheon_postmortem/contact-09-12.jpg create mode 100644 tmp/pdfs/pantheon_postmortem/contact-13-16.jpg create mode 100644 tmp/pdfs/pantheon_postmortem/contact-17-20.jpg create mode 100644 tmp/pdfs/pantheon_postmortem/contact-21-24.jpg create mode 100644 tmp/pdfs/pantheon_postmortem/page-01.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-02.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-03.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-04.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-05.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-06.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-07.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-08.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-09.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-10.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-11.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-12.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-13.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-14.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-15.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-16.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-17.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-18.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-19.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-20.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-21.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-22.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-23.png create mode 100644 tmp/pdfs/pantheon_postmortem/page-24.png create mode 100644 tmp/pdfs/pantheon_postmortem/postmortem-flow.txt create mode 100644 tmp/pdfs/pantheon_postmortem/postmortem.txt create mode 100644 waller_route_light_results.json diff --git a/HANDOFF.md b/HANDOFF.md index 2924df0..6818364 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -8,10 +8,16 @@ iteration rules; `WORKFLOW.md` contains the gate protocol. - Frozen ladder control: `bots/live-v17-control`, downloaded byte-for-byte from active production submission `v17 (dumb bot v6)`. Original archive: `C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v17-20260803/v17-live.zip`. -- Latest observed production status on 2026-08-03: Erebus rank #3/92, rating - 1777, 287 matches, latest ten series 10W-0L. -- Current local candidate: `bots/exp_v17_gunner_control`. It changes only - gunner target/facing control and decisively beat exact v17; details below. +- Latest observed production status on 2026-08-03: Erebus rank #1/92, rating + 1907 after 322 matches, active v22, latest ten 9W-1L. Submission v22 was + activated with explicit user approval; keep it active after future uploads. +- Current production bot: `bots/exp_v21_eco_release_only_damaged` + (submission v22, ID `5861c003-13a8-48c4-bc42-fe01445c6245`). It combines + v20 route completion, v21 spare-opportunity productive-edge repair, and a + bounded economy/defense ownership fix. All local gates and the 50-game + remote threshold pass. Updated Pantheon v14 remains a severe 1-9 weakness, + but pinned v18 also scored 0-5 on the same five maps. v22 is active; root + remains unchanged. - `meta-generalist-v1` remains the exact archived v9 control, but is no longer the parent for new work. - Root `main.py` / `bot.zip` remain the historical v88 package and are dirty in @@ -71,6 +77,10 @@ SHA-256: | `meta-generalist-v1` | archived v9 control; gunner builder-layer bug fix | | `live-v17-control` | active v17; four-builder rush/defense/economy chassis; rank #3 at freeze | | `exp_v17_gunner_control` | validated v17 successor candidate; local only | +| `exp_v18_spawn_route_proof` | submission v19; facing-proven merges; inactive; remote 33-17 | +| `exp_v19_near_core_finish` | submission v20; bounded route completion; inactive; remote 14-11 | +| `exp_v20_opportunistic_trunk_repair` | submission v21; no-travel exact edge repair; remote 14-11; superseded by v22 | +| `exp_v21_eco_release_only_damaged` | submission v22; current active production bot; remote 30-20 | ## Live v17 replay audit and candidate @@ -115,6 +125,187 @@ destructions. Five were early races on Showdown, Runestone, Sprint, or Atoll; one was a turn-990 Sweden loss where 200 damage was fully healed. This confirms early gun races and healed/stalled Sweden remain the live failure categories. +## v18 follow-up audit and spawn candidate + +The 40 v18 unrated replays contain no evidence of our units dying from uncaught +exceptions. The remaining failures exposed home-gun races and one long-game +resource leak: the Sweden loss spawned 56 builders versus 24 while converting +only 200 enemy-core damage. Production 2.3.3 also documents builder build/heal +range as orthogonal only; older repository rules saying diagonal/own-tile are +stale. + +Independent audits tested exact core-threat filtering, legal heal stands, +obstruction-aware gun lines, legal siege/harvest stands, and congestion-aware +spawning. Only `exp_v18_spawn_discipline` passed its mechanism and broad gates. +It preserves the first four builders, then pauses extra spawning while six +friendly builders are inside core vision and resumes after dispersal/death. + +| Production 2.3.3 gate | Candidate | Core kills | v18 baseline | +|---|---:|---:|---:| +| exact v18 | 86/168 (51%) | 73-72 | 50% expectation | +| `spar_rush` | 151/168 (90%) | 150-11 | 154/168 | +| `oogbest-v6` | 146/168 (87%) | 143-15 | 145/168 | +| `meta-generalist-v1` | 146/168 (87%) | 144-17 | 144/168 | +| String/Bridge focus | 18/32 (56%) | 11-10 | head-to-head | + +Across 12 round-1000 parent games it won 8, collected +434 Ti/game, retained ++1,799 Ti, and used 4.7 fewer units. The focused block won four of five +round-1000 games with +1,248 collected Ti/game. Core-threat filtering reached +90/168 and kills 78-63 but lost 94 collected Ti/game; harvest-stand reached +89/168 and kills 78-69 but lost 740 collected Ti/game in round-1000 games. +All other audit variants failed their screens. Nothing was uploaded or activated. + +## Pantheon postmortem cycle + +The Cambridge/Pantheon postmortem validates mechanisms, not a directly portable +bot: its game included foundries, axionite, bridges, roads, breaches, markers, +50x50 maps, and 2000 rounds. Current v18 already overlaps with its memoryless +map layer, symmetry inference, weighted bucket pathfinding, conveyor-cycle +classification, deterministic roles, threat-aware gun plans, and ID-jittered +stuck timeout. Missing architecture-level ideas are per-turn state scoring, +claim/Voronoi ownership, TTL target-failure caches, and incremental map deltas. + +Three isolated ports started from `exp_v18_spawn_discipline`: + +| Experiment | Parent result | Decision | +|---|---:|---| +| passive spare-action heal | fast 3/12, kills 3-9 | reject | +| immediate-buildable siege seat | 80/168, kills 73-82 | reject | +| facing-proven route merge | **86/168, kills 69-68** | pass | + +`exp_v18_spawn_route_proof` treats a visible conveyor as a completed merge sink +only when its facing chain reaches the core, or leaves vision after every +observed edge strictly reduces Manhattan distance to the core. Full 2.3.3 gates: + +| Opponent | Result | Core kills | Spawn-parent baseline | +|---|---:|---:|---:| +| `exp_v18_spawn_discipline` | **86/168** | **69-68** | parity gate | +| `spar_rush` | **152/168** | **151-13** | 151/168 | +| `oogbest-v6` | **148/168** | **141-18** | 146/168 | +| `meta-generalist-v1` | **142/168** | **142-21** | 146/168 | +| String/Bridge parent focus | **21/32** | **15-7** | 18/32 prior block | + +Parent JSON: +10 Ti collected/game, +36 retained Ti/game, 1.2 fewer buildings; +round-1000 games were 10-9. Sixteen paired Bridge replays in +`artifacts/pantheon-route-proof-bridge/` preserved connected conveyors and +served harvesters while reducing total conveyors 18.94 -> 14.69, disconnected +16.81 -> 12.56, and post-round-40 builds 10.00 -> 5.94. It removes waste but +does not fix Bridge team A, which still had zero connected conveyors in both +variants. + +The replay batch exposed an inherited `getDefendHome()` out-of-vision occupant +query that can kill a defender with `GameError`. Two isolated fixes were not +stacked: assumed-empty ring caching gated 83/168 with kills 67-70; a visible- +only/core-return version failed its fast screen 5/12. Keep the bug open rather +than claiming either unproven fallback passed. + +### Remote route-proof and near-core-finisher cycle + +`exp_v18_spawn_route_proof` was packaged as inactive submission v19 +(`a3a732e6-5916-4c40-9088-69a119a3f4d8`, ZIP SHA-256 +`EAAE6E...D7170B3`). Its 50-game unrated sample was **33-17**, estimated core +kills **27-15**: Pantheon 8-2, Orizon 8-2, team lazy 5-5, CtrlAltDefeat 8-2, +SmartFridge 4-6. Replays are in +`C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v19-20260803/`. +The losses showed that facing-proof prevents false merges but does not finish +short routes: an Orizon Bridge loss built 27 conveyors, connected none, and +kept starting branches after combat. + +`exp_v19_near_core_finish` remembers an unfinished route only while its head is +within four Manhattan links of the core footprint, resets on progress, and +abandons after 24 stalled rounds. A post-move vision recheck is mandatory: +without it, querying the old target after movement raises `GameError`. + +| Production 2.3.3 gate | Result | Core kills | Notes | +|---|---:|---:|---| +| exact v19 parent | **88/168** | **72-69** | +193 Ti collected/game | +| `spar_rush` | **158/168** | 158-3 | pass | +| `oogbest-v6` | **142/168** | 138-19 | 3.6 pp below v19, within gate | +| `meta-generalist-v1` | **155/168** | 152-12 | pass | + +In 16 paired Bridge games it cut total conveyors **24.81 -> 6.06**, +disconnected conveyors **23.25 -> 4.00**, and post-round-40 builds +**16.00 -> 1.62**, while increasing connected conveyors **1.56 -> 2.06**. +The fixed source produced no tracebacks. Submission v20 ZIP SHA-256 is +`BE321AA7...EC8D3`; the archive contains only byte-identical `main.py` and +`mapPathfinding.py`. + +The v20 remote sample was **14-11**, estimated core kills **13-11**: Pantheon +4-1, Orizon 3-2, team lazy 1-4, CtrlAltDefeat 4-1, SmartFridge 2-3. Replays are +in `C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v20-20260803/`. +Across all 25 games it averaged 9.00 conveyors: 6.76 connected and 2.24 +disconnected. The surviving failure is narrower than initial routing. In the +turn-640 Pantheon Sweden loss, the network was fully healthy at round 80 +(24/24 conveyors connected, 5/5 harvesters served), then enemy damage severed +the trunk; by round 400 it had 36 disconnected conveyors and only 5/9 +harvesters served. New builders created branches instead of reclaiming the +previously served graph. + +Do not revive the broad sticky-route variant: it scored 83/168 and lost 32.6 +collected Ti/game because builders chased long unfinished paths. Do not use the +rejected opportunistic-pressure override either; it scored 5/12 against v20. +Early remote losses were gun races in which defensive guns spent 13-41 shots +on enemy gunners while the opponent delivered 50-77 core shots. The next +combat change needs deterministic target/front ownership, not more attackers +or a role override. The next route change needs incremental memory of a +formerly connected trunk plus a bounded repair claim; it must never become a +general long-route commitment. + +### v21/v22 productive-edge and ownership cycle + +The first persistent repair followed remembered edges across the map. Despite +large titanium gains it finished **249/504**, core kills **214-218**; a +round-120 sibling finished 82/168, kills 69-71. Gunner preemption was also +rejected at 75/168, kills 61-82: paying 10 Ti and losing the current shot to +rotate away from a turret was worse than finishing it. + +Submission v21 `exp_v20_opportunistic_trunk_repair` keeps only the safe part: +an economy builder remembers an edge it personally observed draining and +rebuilds that exact edge only when a live upstream producer and proven +downstream suffix remain **and** `can_build_conveyor` succeeds immediately. It +never walks, claims, or changes roles for repair. + +| v21 local gate | Result | Core kills | v20 baseline | +|---|---:|---:|---:| +| exact v20 parent | **88/168** | **75-71** | parity | +| `spar_rush` | **156/168** | 156-7 | 158/168 | +| `oogbest-v6` | **139/168** | 136-22 | 142/168 | +| `meta-generalist-v1` | **153/168** | 153-10 | 155/168 | + +Its remote sample was 14-11. Replay review then found the broader ownership +bug: when all visible enemy guns were already covered, every economy builder +called `healCore` and returned. If the core was full or out of sight, the heal +failed and the helper recalled the builder anyway. One team-lazy String loss +therefore reached turn 1000 with zero harvesters and zero conveyors. + +The broad fix (always release covered-threat economy) lost 83/168 with kills +73-79 because healing during actual damage matters. Submission v22 +`exp_v21_eco_release_only_damaged` releases economy only while the friendly +core is full or not locally visible; uncovered threats and visible damaged +cores retain their old priority. + +| v22 local gate | Result | Core kills | v21 baseline | +|---|---:|---:|---:| +| exact v21 parent | **90/168** | **73-73** | parity | +| `spar_rush` | **160/168** | 157-5 | 156/168 | +| `oogbest-v6` | **147/168** | 140-16 | 139/168 | +| `meta-generalist-v1` | **153/168** | 152-9 | 153/168 | + +Ten v22 unrated series scored **30-20**, estimated core kills **30-19**. +Unchanged versions: CtrlAltDefeat 10-0, Orizon 7-3, team lazy 6-4, SmartFridge +6-4. Updated Pantheon v14 was 9-1 against v22 and is new meta evidence, not an +apples-to-apples regression from the earlier v13 control. Across all 50 games +v22 averaged 10.36 connected and 1.32 disconnected conveyors. The new +team-lazy String replay built five harvesters and won by core destruction on +turn 829, directly confirming the ownership fix. Replays: +`C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v22-20260803/`. + +A pinned v18 control against Pantheon v14 on String, Pinch, Bridge, Atoll, and +Sprint scored **0-5**, all core losses. V22 scored 1-4 on its corresponding +five-map series. Pantheon v14 is therefore a chassis-wide new-meta weakness, +not evidence that v22 regressed v18. Control replays: +`C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v18-pantheon-v14-control-20260803/`. + ## Conveyor iteration on production engine 2.3.3 Live observation and local replay audits confirmed the parent can build many @@ -269,6 +460,11 @@ builders. - Local WSL venv: `~/.venvs/fcode`; activate it before tests/gates. - Current local CLI: production `fcode 2.3.3`; all 21 maps are present and the remote session is authenticated. +- Production 2.3.3 builder construction, attack, and healing are orthogonally + adjacent only. Do not trust older same-tile/diagonal comments in `AGENTS.md`. +- Production 2.3.3 still uses gunner damage/cost/HP/ammo 10/10/40/2. The + discussed 5/20/30/4 turret patch is only a proposal; if deployed, invalidate + current numeric gates and retune ammo/resource floors. - Bot `random` is not seeded by engine `--seed`; identical serial games can flip. Use 168 for clear deltas and 336 for close ones. - Per-map gate rows are noisy and gate side labels are unreliable. Use aggregate @@ -291,10 +487,13 @@ From WSL: ```bash cd /mnt/c/Users/subodh/Downloads/fcode source ~/.venvs/fcode/bin/activate -python tests/test_meta_builder_targeting.py -python gate.py meta-generalist-v1 exp_trans_40 8 quiet -python gate.py meta-generalist-v1 spar_rush 4 quiet -fcode run meta-generalist-v1 exp_trans_40 maps/duel.map26 --seed 1 --tle 10 --json +python tests/test_v18_spawn_route_proof.py +python tests/test_v19_near_core_finish.py +python tests/test_v20_opportunistic_trunk_repair.py +python tests/test_v21_eco_release_only_damaged.py +python gate.py exp_v21_eco_release_only_damaged exp_v20_opportunistic_trunk_repair 4 quiet +python gate.py exp_v21_eco_release_only_damaged spar_rush 4 quiet +fcode run exp_v21_eco_release_only_damaged exp_v20_opportunistic_trunk_repair maps/bridge.map26 --seed 1 --tle 10 --json ``` Before any new remote action: install/authenticate the production CLI, run @@ -302,15 +501,19 @@ Before any new remote action: install/authenticate the production CLI, run ## Next steps -1. Keep root, exact v17 control, and the active submission unchanged. -2. Treat `exp_v17_gunner_control` as the only current promotion candidate. - Two independent parent blocks passed (104/168 and 96/168). Isolate its three - submechanisms only if further attribution is worth delaying packaging. -3. Test exact core-threatening-turret filtering for defender 4 independently; - do not stack it before it beats v17 and preserves the gun-control candidate. -4. Investigate the Sprint healed-stalemate and Sweden zero-damage replay - categories separately. Do not change map behavior by map name/dimensions. -5. The user explicitly promoted v18 on 2026-08-03; it is ladder-active. Root - remains unchanged and still requires separate approval. +1. Keep active v22 and root unchanged. Monitor fresh ladder replays for + cross-opponent failure categories before making another behavioral change. +2. Treat `exp_v21_eco_release_only_damaged` as the current production source. + Its local gates and 30-20 remote threshold pass; archive and server members + are byte-identical. +3. Updated Pantheon v14 is the remaining live weakness: ten v22 games scored + 1-9, mostly early multi-gunner core pressure; pinned v18 scored 0-5. Treat it + as a new cross-chassis threat and seek mechanisms that generalize. +4. Do not tune by Pantheon identity or maps. Any response must also improve + unrelated high-pressure families and preserve v22's economy conversion. +5. Do not revive walking/TTL repair, round-gated repair, gunner preemption, or + broad covered-threat release. Their full gates failed. +6. Any upload may auto-activate; restore v22 immediately. Root replacement and + future ladder activation changes require explicit approval. No external action is authorized by this handoff. diff --git a/artifacts/submissions/v19-route-proof.zip b/artifacts/submissions/v19-route-proof.zip new file mode 100644 index 0000000000000000000000000000000000000000..35a86a953cf5b88c63bd015e35a9582b932c4395 GIT binary patch literal 13849 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 HpS%A7Ci3$e literal 0 HcmV?d00001 diff --git a/artifacts/submissions/v21-opportunistic-trunk-repair.zip b/artifacts/submissions/v21-opportunistic-trunk-repair.zip new file mode 100644 index 0000000000000000000000000000000000000000..a44614868dca9865817451f2c81a8e6f7b2288a9 GIT binary patch literal 15526 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 B!zus( literal 0 HcmV?d00001 diff --git a/artifacts/submissions/v21-server.zip b/artifacts/submissions/v21-server.zip new file mode 100644 index 0000000000000000000000000000000000000000..09ecd148f34c0f594c6ae3b666b6062241a2ebb3 GIT binary patch literal 15848 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 literal 0 HcmV?d00001 diff --git a/bots/exp_dynamic_fronts/README.md b/bots/exp_dynamic_fronts/README.md new file mode 100644 index 0000000..68f1586 --- /dev/null +++ b/bots/exp_dynamic_fronts/README.md @@ -0,0 +1,23 @@ +# exp_dynamic_fronts + +Architectural probe from exact production v9 (`meta-generalist-v1`). It keeps +the proven opening, pathfinding, direct siege, and gunner targeting, but +replaces the single home responder and team-wide recall with up to three +distinct local defense fronts. + +The core sizes fronts from verified core-reaching threats versus surviving +friendly gun coverage. It prefers non-siegers, leaves two local builders free +for economy, keeps both siegers on offense, targets weakly covered threats +deterministically, and limits each defender to eight countergun repair actions. +Slots 8-10 publish defender IDs, slot 12 their count, and slots 13-15 their +targets after the opening roles have latched locally. + +Fresh production-engine gates: + +- v9 parent: 92/168 (54.8%), core kills 74-62. +- `spar_rush`: 93/168 (55.4%), core kills 82-64. +- `oogbest-v6`: 89/168 (53.0%), core kills 84-63. + +This is consistent evidence that fixed ownership was a vulnerability, but the +gain is only 1.8-3.6 percentage points over v9 controls. Hold as a chassis +probe; do not package, upload, or promote it. diff --git a/bots/exp_dynamic_fronts/initialSpawning.py b/bots/exp_dynamic_fronts/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_dynamic_fronts/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_dynamic_fronts/main.py b/bots/exp_dynamic_fronts/main.py new file mode 100644 index 0000000..487f30e --- /dev/null +++ b/bots/exp_dynamic_fronts/main.py @@ -0,0 +1,1341 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target. Slots 8-10 +# contain the opening role IDs through round 12, then become three dynamic +# defender IDs. Slot 12 is the defender count and 13-15 are their targets. +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_DEFENSE_COUNT = 12 +DEFENDER_ID_SLOTS = (8, 9, 10) +DEFENDER_TARGET_SLOTS = (13, 14, 15) +OPENING_ROLE_PUBLISH_ROUNDS = 12 +DEFENSE_ECONOMY_FLOOR = 2 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 +FRONT_REPAIR_LIMIT = 8 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def required_defense_fronts( + threat_count: int, covering_guns: int, local_builders: int) -> int: + """Size defense from pressure, while leaving a local economy floor.""" + if threat_count <= 0 or local_builders <= 0: + return 0 + capacity = max(1, local_builders - DEFENSE_ECONOMY_FLOOR) + pressure_gap = max(1, threat_count - covering_guns + 1) + return min(3, threat_count, capacity, pressure_gap) + + +def choose_defense_fronts(candidates, sieger_ids, threats, coverage): + """Assign distinct weakly-covered threats to nearby builders. + + `candidates` is [(id, pos)], `threats` is [(id, pos)], and `coverage` + maps threat IDs to the number of live friendly guns that can answer it. + """ + count = required_defense_fronts( + len(threats), sum(coverage.get(tid, 0) for tid, _ in threats), + len(candidates)) + ordered_threats = sorted( + threats, key=lambda item: (coverage.get(item[0], 0), item[1].x, + item[1].y, item[0])) + unused = list(candidates) + assignments = [] + for threat_id, threat_pos in ordered_threats[:count]: + if not unused: + break + chosen = min( + unused, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat_pos), item[0])) + assignments.append((chosen[0], threat_id, threat_pos)) + unused.remove(chosen) + return assignments + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._baseRole = 0 + self._openingRoleIds = [None, None, None] + self._defenseAssignments = [] + self._homeThreatLastSeen = None + self._defenseTarget = None + self._frontRepairActions = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + self._openingRoleIds[0] = spawned_id + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + self._openingRoleIds[1] = spawned_id + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + self._openingRoleIds[2] = spawned_id + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + if ct.get_current_round() <= OPENING_ROLE_PUBLISH_ROUNDS: + for slot, entity_id in zip(DEFENDER_ID_SLOTS, + self._openingRoleIds): + if entity_id is not None: + ct.write_store(slot, entity_id + 1) + ct.write_store(SLOT_DEFENSE_COUNT, 0) + else: + self._assign_defense_fronts(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._latch_base_role(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + threat = self._assigned_defense_target(ct) + if threat is not None: + if threat != self._defenseTarget: + self._defenseTarget = threat + self._frontRepairActions = 0 + self._run_home_defense(ct, myLoc, threat) + return + self._defenseTarget = None + self._frontRepairActions = 0 + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + return self._baseRole == 1 + + def _is_sieger(self, ct: Controller) -> bool: + return self._baseRole in (2, 3) + + def _sieger_role(self, ct: Controller) -> int: + return self._baseRole - 1 if self._baseRole in (2, 3) else 0 + + def _latch_base_role(self, ct: Controller) -> None: + if self._baseRole or ct.get_current_round() > OPENING_ROLE_PUBLISH_ROUNDS: + return + encoded = ct.get_id() + 1 + for role, slot in enumerate(DEFENDER_ID_SLOTS, start=1): + if ct.read_store(slot) == encoded: + self._baseRole = role + return + + def _assigned_defense_target(self, ct: Controller): + encoded = ct.get_id() + 1 + count = min(3, ct.read_store(SLOT_DEFENSE_COUNT)) + for index in range(count): + if ct.read_store(DEFENDER_ID_SLOTS[index]) == encoded: + return unpack_position( + ct.read_store(DEFENDER_TARGET_SLOTS[index])) + return None + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _threat_coverage(self, ct: Controller, threats): + coverage = {item[3]: 0 for item in threats} + my_team = ct.get_team() + friendly_guns = [] + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) == my_team + and ct.get_entity_type(building_id) + == EntityType.GUNNER): + friendly_guns.append(( + ct.get_position(building_id), + ct.get_direction(building_id))) + for _, _, _, threat_id, threat_pos in threats: + for gun_pos, gun_dir in friendly_guns: + if threat_pos in ct.get_attackable_tiles_from( + gun_pos, gun_dir, EntityType.GUNNER): + coverage[threat_id] += 1 + return coverage + + def _publish_defense_fronts(self, ct: Controller) -> None: + ct.write_store(SLOT_DEFENSE_COUNT, len(self._defenseAssignments)) + for index in range(3): + if index < len(self._defenseAssignments): + builder_id, _, threat_pos = self._defenseAssignments[index] + ct.write_store(DEFENDER_ID_SLOTS[index], builder_id + 1) + ct.write_store( + DEFENDER_TARGET_SLOTS[index], pack_position(threat_pos)) + else: + ct.write_store(DEFENDER_ID_SLOTS[index], 0) + ct.write_store(DEFENDER_TARGET_SLOTS[index], 0) + + def _assign_defense_fronts(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._defenseAssignments = [] + self._publish_defense_fronts(ct) + return + else: + self._publish_defense_fronts(ct) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + sieger_ids = { + entity_id for entity_id in self._openingRoleIds[1:] + if entity_id is not None + } + threat_pairs = [(item[3], item[4]) for item in threats] + self._defenseAssignments = choose_defense_fronts( + candidates, sieger_ids, threat_pairs, + self._threat_coverage(ct, threats)) + self._publish_defense_fronts(ct) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _maintain_covering_gunner( + self, ct: Controller, threat: Position) -> bool: + """Spend a bounded number of actions keeping this front's gun alive.""" + if self._frontRepairActions >= FRONT_REPAIR_LIMIT: + return False + my_team = ct.get_team() + damaged = [] + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + position = ct.get_position(building_id) + if threat not in ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), + EntityType.GUNNER): + continue + missing = ct.get_max_hp(building_id) - ct.get_hp(building_id) + if missing > 0: + damaged.append((-missing, position.x, position.y, position)) + if not damaged: + return False + damaged.sort() + position = damaged[0][3] + if ct.can_heal(position): + ct.heal(position) + self._frontRepairActions += 1 + else: + self.mapPf.moveTo(ct, position) + return True + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + if self._maintain_covering_gunner(ct, threat): + return + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + # Dynamic fronts replace the lossy team-wide recall. Siegers never + # abandon a live offense front merely because home is pressured. + return False + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_dynamic_fronts/mapPathfinding.py b/bots/exp_dynamic_fronts/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_dynamic_fronts/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_dynamic_fronts/symmetry.py b/bots/exp_dynamic_fronts/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_dynamic_fronts/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_late_route_idle/README.md b/bots/exp_late_route_idle/README.md new file mode 100644 index 0000000..cb55609 --- /dev/null +++ b/bots/exp_late_route_idle/README.md @@ -0,0 +1,25 @@ +# Idle Late Route Repair experiment + +**Promotion reject.** Parent gate on engine 2.3.3: 81/168 (48%), core kills +64-77. Round-1000 subset: 17/27 wins and mean titanium 4,017 versus 3,496. +Prioritizing the waller reduced but did not remove the midgame conversion cost. +The successor must not activate until a game is already a true stalemate. + +Parent: frozen `meta-generalist-v1`. Immediate predecessor +`exp_late_route_repair` proved the mechanism but failed promotion: 83/168 +(49.4%), core kills 64-79. It won 19/25 round-1000 games and collected 4,838 +versus 2,785 mean titanium there, but score-six repair claims diverted brawlers. + +This variant keeps the same facing-proven, bounded round-80 repair but changes +only ownership/priority: + +- Prefer the existing wall-role builder, which the parent already forbids from + normal attacks, whenever it is locally visible. +- Exclude both siegers and the active home responder. +- The waller retains the parent's route priority of six. +- A normal builder is only a fallback and receives score 1.5, below meaningful + combat but above idle exploration. + +Everything through round 79 remains parent behavior. No map, dimension, side, +opponent, or new store signal is used. Root, frozen parent, and live package are +untouched. diff --git a/bots/exp_late_route_idle/initialSpawning.py b/bots/exp_late_route_idle/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_late_route_idle/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_late_route_idle/main.py b/bots/exp_late_route_idle/main.py new file mode 100644 index 0000000..5b1bfd9 --- /dev/null +++ b/bots/exp_late_route_idle/main.py @@ -0,0 +1,1345 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 80 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +LATE_ROUTE_FALLBACK_SCORE_CAP = 1.5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer( + candidates, target: Position, excluded_ids=(), preferred_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: ( + 0 if item[0] in preferred_ids else 1, + item[1].distance_squared(target), + item[0], + ), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + wallerEncoded = ct.read_store(SLOT_WALLER_ID) + preferred = {wallerEncoded - 1} if wallerEncoded else set() + return choose_route_repairer( + candidates, target, excluded, preferred) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's exact transition. Only after round 80 may one + # nearest non-specialist finish a short route to an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + scoreCap = ( + LATE_ROUTE_SCORE_CAP + if self._is_waller(ct) + else LATE_ROUTE_FALLBACK_SCORE_CAP + ) + routeScore = min(routeScore, scoreCap) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_late_route_idle/mapPathfinding.py b/bots/exp_late_route_idle/mapPathfinding.py new file mode 100644 index 0000000..0c192e6 --- /dev/null +++ b/bots/exp_late_route_idle/mapPathfinding.py @@ -0,0 +1,643 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_late_route_idle/symmetry.py b/bots/exp_late_route_idle/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_late_route_idle/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_late_route_repair/README.md b/bots/exp_late_route_repair/README.md index 523d559..9ca6375 100644 --- a/bots/exp_late_route_repair/README.md +++ b/bots/exp_late_route_repair/README.md @@ -1,5 +1,12 @@ # Late Route Repair experiment +**Mechanism pass, promotion reject.** Production-engine 2.3.3 parent gate: +83/168 (49.4%), core kills 64-79. In the 25 round-1000 games it won 19, with +mean collected titanium 4,838 versus 2,785. The repair solves the long-game +failure but its score-six claim diverts too many combat turns. Do not promote; +the follow-up must prefer the parent's non-attacking waller and let ordinary +brawlers repair only while idle. + Parent: frozen `meta-generalist-v1` (reported live on ladder). This is the narrowed successor to rejected `exp_connected_economy`, whose diff --git a/bots/exp_meta_counter_tend/README.md b/bots/exp_meta_counter_tend/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_counter_tend/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_counter_tend/initialSpawning.py b/bots/exp_meta_counter_tend/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_counter_tend/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_counter_tend/main.py b/bots/exp_meta_counter_tend/main.py new file mode 100644 index 0000000..84bc0cd --- /dev/null +++ b/bots/exp_meta_counter_tend/main.py @@ -0,0 +1,1267 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_covering_gunner( + self, ct: Controller, threat: Position): + my_team = ct.get_team() + covering = [] + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + position = ct.get_position(building_id) + covering.append((position.x, position.y, building_id)) + if not covering: + return None + covering.sort() + return covering[0][2] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + return self._friendly_covering_gunner(ct, threat) is not None + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + covering_gunner = self._friendly_covering_gunner(ct, threat) + if covering_gunner is not None: + gunner_pos = ct.get_position(covering_gunner) + if (ct.get_hp(covering_gunner) < ct.get_max_hp(covering_gunner) + and ct.can_heal(gunner_pos)): + ct.heal(gunner_pos) + return + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_counter_tend/mapPathfinding.py b/bots/exp_meta_counter_tend/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_counter_tend/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_counter_tend/symmetry.py b/bots/exp_meta_counter_tend/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_counter_tend/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_counter_tend6/README.md b/bots/exp_meta_counter_tend6/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_counter_tend6/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_counter_tend6/initialSpawning.py b/bots/exp_meta_counter_tend6/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_counter_tend6/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_counter_tend6/main.py b/bots/exp_meta_counter_tend6/main.py new file mode 100644 index 0000000..5c72639 --- /dev/null +++ b/bots/exp_meta_counter_tend6/main.py @@ -0,0 +1,1271 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 +COUNTER_TEND_HEAL_LIMIT = 6 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._counterTendHeals = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_covering_gunner( + self, ct: Controller, threat: Position): + my_team = ct.get_team() + covering = [] + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + position = ct.get_position(building_id) + covering.append((position.x, position.y, building_id)) + if not covering: + return None + covering.sort() + return covering[0][2] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + return self._friendly_covering_gunner(ct, threat) is not None + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + covering_gunner = self._friendly_covering_gunner(ct, threat) + if covering_gunner is not None: + gunner_pos = ct.get_position(covering_gunner) + if (self._counterTendHeals < COUNTER_TEND_HEAL_LIMIT + and ct.get_hp(covering_gunner) < ct.get_max_hp(covering_gunner) + and ct.can_heal(gunner_pos)): + ct.heal(gunner_pos) + self._counterTendHeals += 1 + return + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_counter_tend6/mapPathfinding.py b/bots/exp_meta_counter_tend6/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_counter_tend6/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_counter_tend6/symmetry.py b/bots/exp_meta_counter_tend6/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_counter_tend6/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_counter_tend8/README.md b/bots/exp_meta_counter_tend8/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_counter_tend8/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_counter_tend8/initialSpawning.py b/bots/exp_meta_counter_tend8/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_counter_tend8/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_counter_tend8/main.py b/bots/exp_meta_counter_tend8/main.py new file mode 100644 index 0000000..6f83d40 --- /dev/null +++ b/bots/exp_meta_counter_tend8/main.py @@ -0,0 +1,1271 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 +COUNTER_TEND_HEAL_LIMIT = 8 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + self._counterTendHeals = 0 + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_covering_gunner( + self, ct: Controller, threat: Position): + my_team = ct.get_team() + covering = [] + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + position = ct.get_position(building_id) + covering.append((position.x, position.y, building_id)) + if not covering: + return None + covering.sort() + return covering[0][2] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + return self._friendly_covering_gunner(ct, threat) is not None + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + covering_gunner = self._friendly_covering_gunner(ct, threat) + if covering_gunner is not None: + gunner_pos = ct.get_position(covering_gunner) + if (self._counterTendHeals < COUNTER_TEND_HEAL_LIMIT + and ct.get_hp(covering_gunner) < ct.get_max_hp(covering_gunner) + and ct.can_heal(gunner_pos)): + ct.heal(gunner_pos) + self._counterTendHeals += 1 + return + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_counter_tend8/mapPathfinding.py b/bots/exp_meta_counter_tend8/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_counter_tend8/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_counter_tend8/symmetry.py b/bots/exp_meta_counter_tend8/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_counter_tend8/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_damage_sequential/README.md b/bots/exp_meta_damage_sequential/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_damage_sequential/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_damage_sequential/initialSpawning.py b/bots/exp_meta_damage_sequential/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_damage_sequential/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_damage_sequential/main.py b/bots/exp_meta_damage_sequential/main.py new file mode 100644 index 0000000..f522f4f --- /dev/null +++ b/bots/exp_meta_damage_sequential/main.py @@ -0,0 +1,1273 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 +DAMAGE_SEQUENTIAL_HP = 450 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def choose_home_threat(threats, covered_positions, sequential: bool): + """Keep one target until real damage justifies advancing the responder.""" + if not threats: + return None + if sequential: + for threat in threats: + if threat[4] not in covered_positions: + return threat[4] + return threats[0][4] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + sequential = ct.get_hp() < DAMAGE_SEQUENTIAL_HP and len(threats) >= 2 + covered_positions = set() + if sequential: + covered_positions = { + threat[4] + for threat in threats + if self._friendly_gunner_covers(ct, threat[4]) + } + self._homeThreatPos = choose_home_threat( + threats, covered_positions, sequential) + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_damage_sequential/mapPathfinding.py b/bots/exp_meta_damage_sequential/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_damage_sequential/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_damage_sequential/symmetry.py b/bots/exp_meta_damage_sequential/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_damage_sequential/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_damage_sequential400/README.md b/bots/exp_meta_damage_sequential400/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_damage_sequential400/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_damage_sequential400/initialSpawning.py b/bots/exp_meta_damage_sequential400/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_damage_sequential400/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_damage_sequential400/main.py b/bots/exp_meta_damage_sequential400/main.py new file mode 100644 index 0000000..b889f0d --- /dev/null +++ b/bots/exp_meta_damage_sequential400/main.py @@ -0,0 +1,1273 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 +DAMAGE_SEQUENTIAL_HP = 400 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def choose_home_threat(threats, covered_positions, sequential: bool): + """Keep one target until real damage justifies advancing the responder.""" + if not threats: + return None + if sequential: + for threat in threats: + if threat[4] not in covered_positions: + return threat[4] + return threats[0][4] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + sequential = ct.get_hp() < DAMAGE_SEQUENTIAL_HP and len(threats) >= 2 + covered_positions = set() + if sequential: + covered_positions = { + threat[4] + for threat in threats + if self._friendly_gunner_covers(ct, threat[4]) + } + self._homeThreatPos = choose_home_threat( + threats, covered_positions, sequential) + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_damage_sequential400/mapPathfinding.py b/bots/exp_meta_damage_sequential400/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_damage_sequential400/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_damage_sequential400/symmetry.py b/bots/exp_meta_damage_sequential400/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_damage_sequential400/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_multi_recall/README.md b/bots/exp_meta_multi_recall/README.md new file mode 100644 index 0000000..00a0883 --- /dev/null +++ b/bots/exp_meta_multi_recall/README.md @@ -0,0 +1,17 @@ +# Multi-Threat Early Soft Recall experiment + +Parent: exact active-v9 source, `meta-generalist-v1`. + +Fifteen of the latest 25 core-destruction losses began by round 60, and 24 +involved multiple core-firing turrets. V9 raises its recall flag only after core +HP falls below 400, even when several verified shooters are already visible. + +This experiment raises the existing soft recall when either the original +damaged-core condition holds or at least two real core-firing turrets are +visible. Recall semantics are unchanged: established siege seats keep firing; +only traveling or otherwise idle siegers return. One shooter without confirmed +damage does not trigger it. No defender count, economy, spawning, placement, +store slot, map, dimension, side, or opponent behavior changes. + +Local experiment only. It is not packaged, uploaded, activated, or copied to +root. diff --git a/bots/exp_meta_multi_recall/initialSpawning.py b/bots/exp_meta_multi_recall/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_multi_recall/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_multi_recall/main.py b/bots/exp_meta_multi_recall/main.py new file mode 100644 index 0000000..cb8d837 --- /dev/null +++ b/bots/exp_meta_multi_recall/main.py @@ -0,0 +1,1258 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def should_raise_recall(core_hp: int, threat_count: int) -> bool: + return threat_count > 0 and ( + core_hp < RECALL_CORE_HP or threat_count >= 2) + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # One shooter still needs confirmed damage. Multiple real core-firing + # turrets raise the existing soft recall before the first volley lands. + under_attack = should_raise_recall( + ct.get_hp(), len(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_multi_recall/mapPathfinding.py b/bots/exp_meta_multi_recall/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_multi_recall/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_multi_recall/symmetry.py b/bots/exp_meta_multi_recall/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_multi_recall/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_recall450/README.md b/bots/exp_meta_recall450/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_recall450/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_recall450/initialSpawning.py b/bots/exp_meta_recall450/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_recall450/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_recall450/main.py b/bots/exp_meta_recall450/main.py new file mode 100644 index 0000000..4313e42 --- /dev/null +++ b/bots/exp_meta_recall450/main.py @@ -0,0 +1,1254 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +# Begin the existing verified-damage recall after five gunner hits instead of +# ten. A real core-firing threat is still required by runCore. +RECALL_CORE_HP = 450 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_recall450/mapPathfinding.py b/bots/exp_meta_recall450/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_recall450/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_recall450/symmetry.py b/bots/exp_meta_recall450/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_recall450/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_role2_recall/README.md b/bots/exp_meta_role2_recall/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_role2_recall/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_role2_recall/initialSpawning.py b/bots/exp_meta_role2_recall/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_role2_recall/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_role2_recall/main.py b/bots/exp_meta_role2_recall/main.py new file mode 100644 index 0000000..0f88790 --- /dev/null +++ b/bots/exp_meta_role2_recall/main.py @@ -0,0 +1,1271 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +RECALL_NONE = 0 +RECALL_ALL_SIEGERS = 1 +RECALL_SIEGER_2 = 2 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def choose_recall_mode(core_hp: int, threat_count: int) -> int: + """Escalate damaged cores fully; hedge early multi-turret pressure once.""" + if threat_count == 0: + return RECALL_NONE + if core_hp < RECALL_CORE_HP: + return RECALL_ALL_SIEGERS + if threat_count >= 2: + return RECALL_SIEGER_2 + return RECALL_NONE + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # A damaged core keeps the proven two-sieger recall. Before damage, + # multiple real shooters recall only Sieger 2, preserving one race. + threats = self._home_threats(ct) + ct.write_store( + SLOT_RECALL, choose_recall_mode(ct.get_hp(), len(threats))) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + mode = ct.read_store(SLOT_RECALL) + return ( + mode == RECALL_ALL_SIEGERS + or (mode == RECALL_SIEGER_2 and self._sieger_role(ct) == 2) + ) + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_role2_recall/mapPathfinding.py b/bots/exp_meta_role2_recall/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_role2_recall/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_role2_recall/symmetry.py b/bots/exp_meta_role2_recall/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_role2_recall/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_safe_counter60/README.md b/bots/exp_meta_safe_counter60/README.md new file mode 100644 index 0000000..73855de --- /dev/null +++ b/bots/exp_meta_safe_counter60/README.md @@ -0,0 +1,19 @@ +# Meta Generalist v1 — local frozen candidate + +Parent: production v8 source (`exp_trans_40`). + +The only behavior change fixes gunner handling of the engine's separate builder +layer. Enemy builders on otherwise empty tiles are now valid current targets and +contribute the existing weight of 2 during rotation. Friendly builders remain +protected. No opening, economy, spawning, siege, recall, store, map, or opponent +logic changes. + +Validation on local fcode 2.3.2.dev29: + +- 200/336 (60%), core kills 181–127, versus v8. +- 88/168 (52%), core kills 81–64, versus `spar_rush`; v8 control was 92/168 + (55%), kills 85–64, so treat this matchup as neutral/slightly negative. +- 86/168 (51%), core kills 83–67, versus source-diverse `oogbest-v6`. + +This folder and `meta-generalist-v1.zip` are local only. They are not root, +uploaded, queued, or active. diff --git a/bots/exp_meta_safe_counter60/initialSpawning.py b/bots/exp_meta_safe_counter60/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_safe_counter60/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_safe_counter60/main.py b/bots/exp_meta_safe_counter60/main.py new file mode 100644 index 0000000..b25b9d8 --- /dev/null +++ b/bots/exp_meta_safe_counter60/main.py @@ -0,0 +1,1255 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +SAFE_HOME_COUNTER_FLOOR = 60 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + # A verified, zero-exposure home counter should not wait for the forward + # siege reserve. The responder stops spending after one covering gun. + return bounded_trade or resources > SAFE_HOME_COUNTER_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_safe_counter60/mapPathfinding.py b/bots/exp_meta_safe_counter60/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_safe_counter60/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_safe_counter60/symmetry.py b/bots/exp_meta_safe_counter60/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_safe_counter60/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_sequential_responder/README.md b/bots/exp_meta_sequential_responder/README.md new file mode 100644 index 0000000..f5873e6 --- /dev/null +++ b/bots/exp_meta_sequential_responder/README.md @@ -0,0 +1,17 @@ +# Sequential Home Responder experiment + +Parent: exact active-v9 source, `meta-generalist-v1`. + +Across the latest 50 production games, 25 losses ended by core destruction and +23 of those were gunner-only attacks. The existing single responder remains +locked to the lexicographically first live threat after a friendly gun covers +it, leaving later core-firing turrets unanswered. + +This experiment changes only core threat selection. It keeps exactly one local +responder, but selects the first threat not covered by a friendly gunner; when +all are covered, it retains the deterministic first threat and heals normally. +No recall, spawning, economy, siege, counter placement, store slot, map, +dimension, side, or opponent behavior changes. + +Local experiment only. It is not packaged, uploaded, activated, or copied to +root. diff --git a/bots/exp_meta_sequential_responder/initialSpawning.py b/bots/exp_meta_sequential_responder/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_sequential_responder/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_sequential_responder/main.py b/bots/exp_meta_sequential_responder/main.py new file mode 100644 index 0000000..8b05b30 --- /dev/null +++ b/bots/exp_meta_sequential_responder/main.py @@ -0,0 +1,1268 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +def choose_home_threat(threats, covered_positions): + """Advance one responder across uncovered threats deterministically.""" + if not threats: + return None + for threat in threats: + if threat[4] not in covered_positions: + return threat[4] + return threats[0][4] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + covered_positions = { + threat[4] + for threat in threats + if self._friendly_gunner_covers(ct, threat[4]) + } + self._homeThreatPos = choose_home_threat( + threats, covered_positions) + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_sequential_responder/mapPathfinding.py b/bots/exp_meta_sequential_responder/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_sequential_responder/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_sequential_responder/symmetry.py b/bots/exp_meta_sequential_responder/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_sequential_responder/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_meta_single_countertrade/README.md b/bots/exp_meta_single_countertrade/README.md new file mode 100644 index 0000000..67daf99 --- /dev/null +++ b/bots/exp_meta_single_countertrade/README.md @@ -0,0 +1,17 @@ +# Single-Threat Countertrade experiment + +Parent: exact active-v9 source, `meta-generalist-v1`. + +The production audit found two core losses where one enemy gunner fired 44 and +83 times without being removed. V9 searches for a fully safe counter seat, but +its bounded exposed-seat fallback is disabled unless two core-firing turrets +already exist. + +This experiment permits that existing bounded countertrade for one or more +visible core-firing turrets. The builder stand must remain outside every attack +pattern and the gunner seat may be exposed to at most one turret. All placement, +budget, responder, recall, economy, siege, spawning, and protocol behavior is +otherwise byte-identical to v9. + +Local experiment only. It is not packaged, uploaded, activated, or copied to +root. diff --git a/bots/exp_meta_single_countertrade/initialSpawning.py b/bots/exp_meta_single_countertrade/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_meta_single_countertrade/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_meta_single_countertrade/main.py b/bots/exp_meta_single_countertrade/main.py new file mode 100644 index 0000000..ba22fe5 --- /dev/null +++ b/bots/exp_meta_single_countertrade/main.py @@ -0,0 +1,1252 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if not turret_attacks: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # After the cutoff the economy is what it is; every action goes to guns. + if ct.get_current_round() >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_meta_single_countertrade/mapPathfinding.py b/bots/exp_meta_single_countertrade/mapPathfinding.py new file mode 100644 index 0000000..5669e6f --- /dev/null +++ b/bots/exp_meta_single_countertrade/mapPathfinding.py @@ -0,0 +1,392 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] \ No newline at end of file diff --git a/bots/exp_meta_single_countertrade/symmetry.py b/bots/exp_meta_single_countertrade/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_meta_single_countertrade/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_route_integrity/README.md b/bots/exp_route_integrity/README.md new file mode 100644 index 0000000..c2f1e7d --- /dev/null +++ b/bots/exp_route_integrity/README.md @@ -0,0 +1,16 @@ +# Route Integrity experiment + +Parent: `exp_waller_route_light`, itself an exact-v9 branch. + +Production v9 replays exposed a four-link conveyor cycle trapping a +seven-conveyor disconnected branch in a round-1000 titanium loss. The inherited +router seeds every visible friendly conveyor as a +completed trunk without following its facing. This branch keeps the bounded +waller-only late repair and makes one opening-router change: a visible merge +target is accepted only if its facing chain reaches the core, or if the chain +leaves vision after making strict progress toward the core on every observed +edge. Visible cycles and dead ends are rejected. + +The rule uses only sensed topology and facing. It contains no map, side, +dimension, opponent, or remote-match fingerprint. It is a local experiment, +not packaged, uploaded, activated, or copied to root. diff --git a/bots/exp_route_integrity/initialSpawning.py b/bots/exp_route_integrity/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_route_integrity/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_route_integrity/main.py b/bots/exp_route_integrity/main.py new file mode 100644 index 0000000..68854c1 --- /dev/null +++ b/bots/exp_route_integrity/main.py @@ -0,0 +1,1345 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 120 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +LATE_ROUTE_FALLBACK_SCORE_CAP = 1.5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer( + candidates, target: Position, excluded_ids=(), preferred_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: ( + 0 if item[0] in preferred_ids else 1, + item[1].distance_squared(target), + item[0], + ), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if (ct.get_current_round() >= LATE_ROUTE_REPAIR_START + and self._is_waller(ct)): + self.mapPf.refreshEconomyGraph(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + wallerEncoded = ct.read_store(SLOT_WALLER_ID) + preferred = {wallerEncoded - 1} if wallerEncoded else set() + return choose_route_repairer( + candidates, target, excluded, preferred) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's exact transition. Only after round 80 may one + # nearest non-specialist finish a short route to an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._is_waller(ct) + or healScore > 0 + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_route_integrity/mapPathfinding.py b/bots/exp_route_integrity/mapPathfinding.py new file mode 100644 index 0000000..68e0448 --- /dev/null +++ b/bots/exp_route_integrity/mapPathfinding.py @@ -0,0 +1,702 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +def visible_conveyor_merge_safe( + start, conveyor_directions, core_tiles, visible_tiles): + """Reject visible dead ends/cycles before treating a conveyor as a trunk. + + A fully visible chain is accepted only when it actually reaches the core. + If an otherwise valid-looking chain leaves vision, every observed edge must + make strict Manhattan progress toward the core. This preserves the parent's + cheap local merge optimization without pretending arbitrary conveyors are + connected. + """ + position = start + visited = set() + made_only_progress = True + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return False + output = position.add(facing) + current_distance = min( + abs(position.x - tile.x) + abs(position.y - tile.y) + for tile in core_tiles + ) + output_distance = min( + abs(output.x - tile.x) + abs(output.y - tile.y) + for tile in core_tiles + ) + made_only_progress &= output_distance < current_distance + if output in core_tiles: + return True + if output in conveyor_directions: + position = output + continue + if output in visible_tiles: + return False + return made_only_progress + return False + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def refreshEconomyGraph(self, ct: Controller): + """Refresh graph state only when the designated repairer needs it.""" + for tile in ct.get_nearby_tiles(): + self.observeEconomyTile(ct, tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + nearbyBuildings = ct.get_nearby_buildings() + visibleTiles = set(ct.get_nearby_tiles()) + conveyorDirections = {} + conveyorIds = {} + for b in nearbyBuildings: + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.CONVEYOR): + position = ct.get_position(b) + conveyorDirections[position] = ct.get_direction(b) + conveyorIds[position] = b + coreTiles = core_footprint_positions(self.teamCore) + safeMerges = { + position for position in conveyorDirections + if visible_conveyor_merge_safe( + position, conveyorDirections, coreTiles, visibleTiles) + } + for bPos in safeMerges: + b = conveyorIds[bPos] + bPos = ct.get_position(b) + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_route_integrity/symmetry.py b/bots/exp_route_integrity/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_route_integrity/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_stalemate_route_repair/README.md b/bots/exp_stalemate_route_repair/README.md new file mode 100644 index 0000000..b69e397 --- /dev/null +++ b/bots/exp_stalemate_route_repair/README.md @@ -0,0 +1,27 @@ +# Stalemate Route Repair experiment + +**Promotion reject.** Parent gate: 82/168 (49%), core kills 70-71. Combat was +preserved, but round 300 was too late: the 26 round-1000 games went 12-14 and +mean titanium was 3,528 versus 3,565. Preserve as boundary evidence. + +Parent: frozen `meta-generalist-v1`. + +Two earlier independent gates proved that facing-aware orphan repair improves +round-1000 titanium but that round-80 activation costs core conversions: + +- Broad rewrite: rejected at 3/12, core kills 3-9. +- Bounded round-80 repair: 83/168, core kills 64-79; won 19/25 round-1000 + games with 4,838 versus 2,785 mean titanium. +- Waller-first round-80 repair: 81/168, core kills 64-77; won 17/27 round-1000 + games with 4,017 versus 3,496 mean titanium. + +This experiment changes one boundary: repair cannot activate before round 300. +The opening, economy, pressure transition, and full normal combat window remain +the parent behavior. In a surviving stalemate, the waller is preferred; a +normal builder repairs only while idle at score 1.5. Routes are bounded to eight +links, start with at least 100 titanium, follow real conveyor facings, avoid +loaded/unproven trunks, and can surgically rebuild one wrong-facing or cyclic +link rooted at an orphan harvester. + +No map, dimensions, side, opponent, or new store state is used. Root, frozen +parent, and live package remain untouched. diff --git a/bots/exp_stalemate_route_repair/initialSpawning.py b/bots/exp_stalemate_route_repair/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_stalemate_route_repair/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_stalemate_route_repair/main.py b/bots/exp_stalemate_route_repair/main.py new file mode 100644 index 0000000..4fb49fb --- /dev/null +++ b/bots/exp_stalemate_route_repair/main.py @@ -0,0 +1,1345 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 300 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +LATE_ROUTE_FALLBACK_SCORE_CAP = 1.5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer( + candidates, target: Position, excluded_ids=(), preferred_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: ( + 0 if item[0] in preferred_ids else 1, + item[1].distance_squared(target), + item[0], + ), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + wallerEncoded = ct.read_store(SLOT_WALLER_ID) + preferred = {wallerEncoded - 1} if wallerEncoded else set() + return choose_route_repairer( + candidates, target, excluded, preferred) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's full normal combat window. Only in a surviving + # round-300 stalemate may one builder repair an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + scoreCap = ( + LATE_ROUTE_SCORE_CAP + if self._is_waller(ct) + else LATE_ROUTE_FALLBACK_SCORE_CAP + ) + routeScore = min(routeScore, scoreCap) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_stalemate_route_repair/mapPathfinding.py b/bots/exp_stalemate_route_repair/mapPathfinding.py new file mode 100644 index 0000000..0c192e6 --- /dev/null +++ b/bots/exp_stalemate_route_repair/mapPathfinding.py @@ -0,0 +1,643 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_stalemate_route_repair/symmetry.py b/bots/exp_stalemate_route_repair/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_stalemate_route_repair/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_v18_build_stands/README.md b/bots/exp_v18_build_stands/README.md new file mode 100644 index 0000000..bfb8cb0 --- /dev/null +++ b/bots/exp_v18_build_stands/README.md @@ -0,0 +1,28 @@ +# v18 legal build-stand experiment + +Independent from the active v18 source. Production 2.3.3 permits builder +construction only from an orthogonally adjacent tile. Attackers standing on a +planned gun seat and economy builders standing on ore now step off one tile and +build next turn instead of retreating to their own core. + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. diff --git a/bots/exp_v18_build_stands/main.py b/bots/exp_v18_build_stands/main.py new file mode 100644 index 0000000..6a3070e --- /dev/null +++ b/bots/exp_v18_build_stands/main.py @@ -0,0 +1,541 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + # Production 2.3.3 only permits orthogonally adjacent + # builds. Step behind the planned seat instead of + # abandoning the siege and walking all the way home. + self.stepOffBuildTile( + ct, self.mapPf.enemyCorePos, preferFarther=True) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def stepOffBuildTile(self, ct: Controller, reference: Position, + preferFarther: bool) -> bool: + """Leave an intended build tile while staying cardinally adjacent.""" + myLoc = ct.get_position() + dirs = list(CARDINALS) + dirs.sort( + key=lambda d: myLoc.add(d).distance_squared(reference), + reverse=preferFarther, + ) + for d in dirs: + if ct.can_move(d): + ct.move(d) + return True + return False + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + # As with gun seats, an ore tile is not a legal same-tile build. + # Step toward the core so the harvester can be built next turn. + self.stepOffBuildTile(ct, teamCore, preferFarther=False) + return True + diff --git a/bots/exp_v18_build_stands/mapPathfinding.py b/bots/exp_v18_build_stands/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_build_stands/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_core_threat_filter/README.md b/bots/exp_v18_core_threat_filter/README.md new file mode 100644 index 0000000..704837c --- /dev/null +++ b/bots/exp_v18_core_threat_filter/README.md @@ -0,0 +1,27 @@ +# v18 core-threat filter experiment + +Independent from the active v18 source. The designated home defender reacts +only to an enemy gunner or sentinel whose current attack pattern reaches a tile +of the friendly core; unrelated visible turrets no longer pull it away. + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. diff --git a/bots/exp_v18_core_threat_filter/main.py b/bots/exp_v18_core_threat_filter/main.py new file mode 100644 index 0000000..30251c2 --- /dev/null +++ b/bots/exp_v18_core_threat_filter/main.py @@ -0,0 +1,541 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) != myTeam and + ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL) and + self.turretThreatensCore(ct, b)): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def turretThreatensCore(self, ct: Controller, turretId: int) -> bool: + """True only when the turret's current facing reaches our core.""" + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + coreTiles = { + teamCore, + teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST), + } + turretType = ct.get_entity_type(turretId) + attackable = ct.get_attackable_tiles_from( + ct.get_position(turretId), + ct.get_direction(turretId), + turretType, + ) + return any(tile in coreTiles for tile in attackable) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_core_threat_filter/mapPathfinding.py b/bots/exp_v18_core_threat_filter/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_core_threat_filter/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_harvest_stand/README.md b/bots/exp_v18_harvest_stand/README.md new file mode 100644 index 0000000..693dd85 --- /dev/null +++ b/bots/exp_v18_harvest_stand/README.md @@ -0,0 +1,27 @@ +# v18 legal harvest-stand experiment + +Independent from active v18. An economy builder standing directly on uncovered +ore now steps one cardinal tile toward home, allowing a production-legal +adjacent harvester build next turn, instead of abandoning the ore. + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. diff --git a/bots/exp_v18_harvest_stand/main.py b/bots/exp_v18_harvest_stand/main.py new file mode 100644 index 0000000..433fe7d --- /dev/null +++ b/bots/exp_v18_harvest_stand/main.py @@ -0,0 +1,531 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.stepOffHarvestTile(ct, teamCore) + return True + + def stepOffHarvestTile(self, ct: Controller, teamCore: Position) -> bool: + """Leave an ore tile toward home so it can be built next turn.""" + myLoc = ct.get_position() + dirs = sorted(CARDINALS, + key=lambda d: myLoc.add(d).distance_squared(teamCore)) + for d in dirs: + if ct.can_move(d): + ct.move(d) + return True + return False + diff --git a/bots/exp_v18_harvest_stand/mapPathfinding.py b/bots/exp_v18_harvest_stand/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_harvest_stand/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_heal_stand/README.md b/bots/exp_v18_heal_stand/README.md new file mode 100644 index 0000000..bd948b7 --- /dev/null +++ b/bots/exp_v18_heal_stand/README.md @@ -0,0 +1,27 @@ +# v18 legal heal-stand experiment + +Independent from the active v18 source. Production 2.3.3 permits builder +healing only on orthogonally adjacent tiles. The home selector now excludes the +four diagonal core corners where a defender could previously park but not heal. + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. diff --git a/bots/exp_v18_heal_stand/main.py b/bots/exp_v18_heal_stand/main.py new file mode 100644 index 0000000..0bd975f --- /dev/null +++ b/bots/exp_v18_heal_stand/main.py @@ -0,0 +1,530 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def isCoreHealStand(tile: Position, teamCore: Position) -> bool: + """Builders can heal only an orthogonally adjacent core tile.""" + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + return any(tile.distance_squared(coreTile) == 1 for coreTile in coreTiles) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + if not isCoreHealStand(tile, teamCore): + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_heal_stand/mapPathfinding.py b/bots/exp_v18_heal_stand/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_heal_stand/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_line_integrity/README.md b/bots/exp_v18_line_integrity/README.md new file mode 100644 index 0000000..c5176cd --- /dev/null +++ b/bots/exp_v18_line_integrity/README.md @@ -0,0 +1,28 @@ +# v18 firing-line integrity experiment + +Independent from the active v18 source. It rejects builder-occupied siege +seats, measures defensive coverage with `can_fire_from`, includes friendly +builders in ray blocking, and scores only the first occupied tile of a gunner +ray instead of enemies hidden behind friendly blockers. + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. diff --git a/bots/exp_v18_line_integrity/main.py b/bots/exp_v18_line_integrity/main.py new file mode 100644 index 0000000..0dac1be --- /dev/null +++ b/bots/exp_v18_line_integrity/main.py @@ -0,0 +1,543 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_builder_bot_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + builderHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None: + if ct.get_team(tileId) == myTeam: + break + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + break + if bbId is not None: + if ct.get_team(bbId) != myTeam: + builderHits += 1 + break + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, builderHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if not self.friendlyTurretCanHit(ct, g)] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def friendlyTurretCanHit(self, ct: Controller, target: Position) -> bool: + """Whether a real friendly turret has an unobstructed ray to target.""" + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + if ct.can_fire_from(ct.get_position(b), ct.get_direction(b), bType, target): + return True + return False + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + bbId = ct.get_tile_builder_bot_id(Position(x, y)) + if bbId is not None and ct.get_team(bbId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if not self.friendlyTurretCanHit(ct, g)] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_line_integrity/mapPathfinding.py b/bots/exp_v18_line_integrity/mapPathfinding.py new file mode 100644 index 0000000..ee3e566 --- /dev/null +++ b/bots/exp_v18_line_integrity/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType in (EntityType.BARRIER, EntityType.GUNNER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_siege_stand/README.md b/bots/exp_v18_siege_stand/README.md new file mode 100644 index 0000000..4a169ca --- /dev/null +++ b/bots/exp_v18_siege_stand/README.md @@ -0,0 +1,27 @@ +# v18 legal siege-stand experiment + +Independent from active v18. An attacker standing on its planned gun seat now +steps one cardinal tile behind it, allowing the production-legal adjacent build +next turn, instead of abandoning the siege and walking home. + +Exact production v17 with only gunner targeting/rotation corrected: + +- fire at lone enemy builders on the separate builder layer; +- prioritize enemy gunners whose real position/facing can hit our core; +- preserve the current facing on equal scores instead of spending 10 Ti on a + lateral rotation. + +Fresh production-engine gates (21 maps, four seeds, both sides): + +| Gate | Candidate | Control | Core kills | +|---|---:|---:|---:| +| exact v17 head-to-head | 200/336 (59.5%) | 136/336 | 187-128 | +| vs `oogbest-v6` | 145/168 | 140/168 | candidate 143-18; control 139-25 | +| vs `meta-generalist-v1` | 144/168 | 142/168 | candidate 142-19; control 140-25 | + +Deterministic tests are in `tests/test_v17_gunner_control.py`. This is a local +candidate packaged as submission v18 (`v17 gun-control candidate`). Eight +unranked series against replay-loss opponents scored 34/40. The first batch was +Pantheon 5-0, Askar City 5-0, team lazy 3-2, CtrlAltDefeat 4-1, and the one +piece 5-0; repeats were SmartFridge 3-2, team lazy 4-1, and CtrlAltDefeat 5-0. +v18 was explicitly promoted to the active ladder submission on 2026-08-03. diff --git a/bots/exp_v18_siege_stand/main.py b/bots/exp_v18_siege_stand/main.py new file mode 100644 index 0000000..c99dd86 --- /dev/null +++ b/bots/exp_v18_siege_stand/main.py @@ -0,0 +1,533 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if self.numSpawned < 4 or globalTitanium > 360: + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.stepOffBuildTile(ct, self.mapPf.enemyCorePos, + preferFarther=True) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def stepOffBuildTile(self, ct: Controller, reference: Position, + preferFarther: bool) -> bool: + myLoc = ct.get_position() + dirs = list(CARDINALS) + dirs.sort(key=lambda d: myLoc.add(d).distance_squared(reference), + reverse=preferFarther) + for d in dirs: + if ct.can_move(d): + ct.move(d) + return True + return False + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_siege_stand/mapPathfinding.py b/bots/exp_v18_siege_stand/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_siege_stand/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_spawn_discipline/README.md b/bots/exp_v18_spawn_discipline/README.md new file mode 100644 index 0000000..cbf5d33 --- /dev/null +++ b/bots/exp_v18_spawn_discipline/README.md @@ -0,0 +1,21 @@ +# v18 spawn-discipline experiment + +Independent from the active v18 source. The first four builders and their roles +are unchanged. Later spawning pauses only while six friendly builders are +already inside core vision, and resumes after they disperse or die. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v18 baseline | +|---|---:|---:|---:| +| exact v18 head-to-head | 86/168 (51.2%) | 73-72 | 50% expectation | +| vs `spar_rush` | 151/168 (89.9%) | 150-11 | 154/168 | +| vs `oogbest-v6` | 146/168 (86.9%) | 143-15 | 145/168 | +| vs `meta-generalist-v1` | 146/168 (86.9%) | 144-17 | 144/168 | +| String/Bridge focus | 18/32 (56.2%) | 11-10 | head-to-head | + +In the 12 round-1000 parent games it won eight, collected 434 more titanium +per game, retained 1,799 more titanium, and used 4.7 fewer units. In the focused +String/Bridge block it won four of five round-1000 games and collected 1,248 +more titanium per game. Deterministic coverage is in +`tests/test_v18_spawn_discipline.py`. This candidate is local and inactive. diff --git a/bots/exp_v18_spawn_discipline/main.py b/bots/exp_v18_spawn_discipline/main.py new file mode 100644 index 0000000..9d17fab --- /dev/null +++ b/bots/exp_v18_spawn_discipline/main.py @@ -0,0 +1,534 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_spawn_discipline/mapPathfinding.py b/bots/exp_v18_spawn_discipline/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_spawn_discipline/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_spawn_immediate_seat/README.md b/bots/exp_v18_spawn_immediate_seat/README.md new file mode 100644 index 0000000..b874c57 --- /dev/null +++ b/bots/exp_v18_spawn_immediate_seat/README.md @@ -0,0 +1,22 @@ +# v18 spawn discipline + immediate siege seat + +Independent child of `bots/exp_v18_spawn_discipline`. Direct siege now prefers +a legal gunner placement it can build this turn before walking toward another +equivalent firing plan, and it rejects seats occupied by a builder. Targeting, +movement, routing, defense, and spawning are otherwise unchanged. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v18 baseline | +|---|---:|---:|---:| +| exact v18 head-to-head | 86/168 (51.2%) | 73-72 | 50% expectation | +| vs `spar_rush` | 151/168 (89.9%) | 150-11 | 154/168 | +| vs `oogbest-v6` | 146/168 (86.9%) | 143-15 | 145/168 | +| vs `meta-generalist-v1` | 146/168 (86.9%) | 144-17 | 144/168 | +| String/Bridge focus | 18/32 (56.2%) | 11-10 | head-to-head | + +In the 12 round-1000 parent games it won eight, collected 434 more titanium +per game, retained 1,799 more titanium, and used 4.7 fewer units. In the focused +String/Bridge block it won four of five round-1000 games and collected 1,248 +more titanium per game. Deterministic coverage is in +`tests/test_v18_spawn_discipline.py`. This candidate is local and inactive. diff --git a/bots/exp_v18_spawn_immediate_seat/main.py b/bots/exp_v18_spawn_immediate_seat/main.py new file mode 100644 index 0000000..d251bb7 --- /dev/null +++ b/bots/exp_v18_spawn_immediate_seat/main.py @@ -0,0 +1,545 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def gunnerSeatScore(buildableNow, seatCoverage, builderDistance, coreDistance): + """Prefer an immediate legal shot before walking toward equivalent plans.""" + return (0 if buildableNow else 1, seatCoverage, + builderDistance, coreDistance) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_builder_bot_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = gunnerSeatScore( + ct.can_build_gunner(spotPos, spotDir), seatCov, + myLoc.distance_squared(spotPos), + spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_spawn_immediate_seat/mapPathfinding.py b/bots/exp_v18_spawn_immediate_seat/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_spawn_immediate_seat/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_spawn_passive_heal/README.md b/bots/exp_v18_spawn_passive_heal/README.md new file mode 100644 index 0000000..8fa04c4 --- /dev/null +++ b/bots/exp_v18_spawn_passive_heal/README.md @@ -0,0 +1,22 @@ +# v18 spawn discipline + passive heal + +Independent child of `bots/exp_v18_spawn_discipline`. After normal role logic, +a builder with a spare action and more than 28 Ti may heal one adjacent friendly +building. It prioritizes core, turrets, economy, conveyors, then barriers. Role +selection, movement, combat, routing, and spawning are otherwise unchanged. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v18 baseline | +|---|---:|---:|---:| +| exact v18 head-to-head | 86/168 (51.2%) | 73-72 | 50% expectation | +| vs `spar_rush` | 151/168 (89.9%) | 150-11 | 154/168 | +| vs `oogbest-v6` | 146/168 (86.9%) | 143-15 | 145/168 | +| vs `meta-generalist-v1` | 146/168 (86.9%) | 144-17 | 144/168 | +| String/Bridge focus | 18/32 (56.2%) | 11-10 | head-to-head | + +In the 12 round-1000 parent games it won eight, collected 434 more titanium +per game, retained 1,799 more titanium, and used 4.7 fewer units. In the focused +String/Bridge block it won four of five round-1000 games and collected 1,248 +more titanium per game. Deterministic coverage is in +`tests/test_v18_spawn_discipline.py`. This candidate is local and inactive. diff --git a/bots/exp_v18_spawn_passive_heal/main.py b/bots/exp_v18_spawn_passive_heal/main.py new file mode 100644 index 0000000..bade5bd --- /dev/null +++ b/bots/exp_v18_spawn_passive_heal/main.py @@ -0,0 +1,586 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +PASSIVE_HEAL_TITANIUM_FLOOR = 28 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def passiveHealPriority(entityType): + """Rank buildings for Pantheon-style spare-action healing.""" + return { + EntityType.CORE: 5, + EntityType.GUNNER: 4, + EntityType.SENTINEL: 4, + EntityType.LAUNCHER: 4, + EntityType.HARVESTER: 3, + EntityType.CONVEYOR: 2, + EntityType.SPLITTER: 2, + EntityType.BARRIER: 1, + }.get(entityType, 0) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + self.tryPassiveHeal(ct) + + def tryPassiveHeal(self, ct: Controller) -> bool: + """Spend an otherwise-unused action on the best adjacent building. + + This never interrupts a role action: it only runs after attack, defense, + or economy logic and respects the core's existing 28 Ti reserve. + """ + if (ct.get_action_cooldown() != 0 or + ct.get_global_resources() <= PASSIVE_HEAL_TITANIUM_FLOOR): + return False + myLoc = ct.get_position() + myTeam = ct.get_team() + candidates = [] + seen = set() + for directionIndex, d in enumerate(CARDINALS): + tile = myLoc.add(d) + buildingId = ct.get_tile_building_id(tile) + if buildingId is None or buildingId in seen: + continue + seen.add(buildingId) + if ct.get_team(buildingId) != myTeam: + continue + missingHp = ct.get_max_hp(buildingId) - ct.get_hp(buildingId) + if missingHp <= 0: + continue + priority = passiveHealPriority(ct.get_entity_type(buildingId)) + if priority <= 0: + continue + candidates.append((-priority, -missingHp, directionIndex, + buildingId, tile)) + candidates.sort() + for _, _, _, _, tile in candidates: + if ct.can_heal(tile): + ct.heal(tile) + return True + return False + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_spawn_passive_heal/mapPathfinding.py b/bots/exp_v18_spawn_passive_heal/mapPathfinding.py new file mode 100644 index 0000000..6fb8db8 --- /dev/null +++ b/bots/exp_v18_spawn_passive_heal/mapPathfinding.py @@ -0,0 +1,670 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + self.convRound = curRound + return convDirs, convLoop + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key]: + continue # cycle: not a sink, so no seed. still reached by normal expansion below + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_spawn_route_proof/README.md b/bots/exp_v18_spawn_route_proof/README.md new file mode 100644 index 0000000..f6443f3 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof/README.md @@ -0,0 +1,24 @@ +# v18 spawn discipline + route proof + +Independent child of `bots/exp_v18_spawn_discipline`. Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | spawn-parent baseline | +|---|---:|---:|---:| +| exact spawn parent | 86/168 (51.2%) | 69-68 | 50% expectation | +| vs `spar_rush` | 152/168 (90.5%) | 151-13 | 151/168 | +| vs `oogbest-v6` | 148/168 (88.1%) | 141-18 | 146/168 | +| vs `meta-generalist-v1` | 142/168 (84.5%) | 142-21 | 146/168 | +| String/Bridge focus | 21/32 (65.6%) | 15-7 | 18/32 prior block | + +Across the 168 parent games it collected 10 more titanium per game, retained 36 +more, and used 1.2 fewer buildings. It won 10/19 round-1000 games. Sixteen +paired Bridge replays preserved connected routes and served harvesters while +reducing total conveyors 18.94 to 14.69, disconnected conveyors 16.81 to 12.56, +and post-round-40 construction 10.00 to 5.94. Deterministic coverage is in +`tests/test_v18_spawn_route_proof.py`. This candidate is local and inactive. diff --git a/bots/exp_v18_spawn_route_proof/main.py b/bots/exp_v18_spawn_route_proof/main.py new file mode 100644 index 0000000..9d17fab --- /dev/null +++ b/bots/exp_v18_spawn_route_proof/main.py @@ -0,0 +1,534 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_spawn_route_proof/mapPathfinding.py b/bots/exp_v18_spawn_route_proof/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_spawn_route_proof_guard/README.md b/bots/exp_v18_spawn_route_proof_guard/README.md new file mode 100644 index 0000000..3814fa0 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof_guard/README.md @@ -0,0 +1,27 @@ +# v18 spawn discipline + route proof + vision guard + +Independent child of `bots/exp_v18_spawn_discipline`. Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +The home-position planner also avoids querying core-ring occupants outside the +builder's current vision. This prevents the production `GameError` observed in +a paired Bridge replay without changing the selected home tile once visible. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v18 baseline | +|---|---:|---:|---:| +| exact v18 head-to-head | 86/168 (51.2%) | 73-72 | 50% expectation | +| vs `spar_rush` | 151/168 (89.9%) | 150-11 | 154/168 | +| vs `oogbest-v6` | 146/168 (86.9%) | 143-15 | 145/168 | +| vs `meta-generalist-v1` | 146/168 (86.9%) | 144-17 | 144/168 | +| String/Bridge focus | 18/32 (56.2%) | 11-10 | head-to-head | + +In the 12 round-1000 parent games it won eight, collected 434 more titanium +per game, retained 1,799 more titanium, and used 4.7 fewer units. In the focused +String/Bridge block it won four of five round-1000 games and collected 1,248 +more titanium per game. Deterministic coverage is in +`tests/test_v18_spawn_discipline.py`. This candidate is local and inactive. diff --git a/bots/exp_v18_spawn_route_proof_guard/main.py b/bots/exp_v18_spawn_route_proof_guard/main.py new file mode 100644 index 0000000..67f0a87 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof_guard/main.py @@ -0,0 +1,538 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + # Controller tile-entity queries are vision-gated. A defender + # spawned far from home still knows the core ring from shared + # map state, but must not query its live occupant until close. + if (ct.is_in_vision(tile) and + ct.get_tile_building_id(tile) is not None): + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_spawn_route_proof_guard/mapPathfinding.py b/bots/exp_v18_spawn_route_proof_guard/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof_guard/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v18_spawn_route_proof_vision_skip/README.md b/bots/exp_v18_spawn_route_proof_vision_skip/README.md new file mode 100644 index 0000000..1b7879d --- /dev/null +++ b/bots/exp_v18_spawn_route_proof_vision_skip/README.md @@ -0,0 +1,27 @@ +# v18 spawn discipline + route proof + vision-safe return + +Independent child of `bots/exp_v18_spawn_discipline`. Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +The home planner never queries or caches a core-ring tile outside current +vision. A distant defender approaches the known core coordinate, then chooses +its original ranked ring position once the ring is actually visible. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v18 baseline | +|---|---:|---:|---:| +| exact v18 head-to-head | 86/168 (51.2%) | 73-72 | 50% expectation | +| vs `spar_rush` | 151/168 (89.9%) | 150-11 | 154/168 | +| vs `oogbest-v6` | 146/168 (86.9%) | 143-15 | 145/168 | +| vs `meta-generalist-v1` | 146/168 (86.9%) | 144-17 | 144/168 | +| String/Bridge focus | 18/32 (56.2%) | 11-10 | head-to-head | + +In the 12 round-1000 parent games it won eight, collected 434 more titanium +per game, retained 1,799 more titanium, and used 4.7 fewer units. In the focused +String/Bridge block it won four of five round-1000 games and collected 1,248 +more titanium per game. Deterministic coverage is in +`tests/test_v18_spawn_discipline.py`. This candidate is local and inactive. diff --git a/bots/exp_v18_spawn_route_proof_vision_skip/main.py b/bots/exp_v18_spawn_route_proof_vision_skip/main.py new file mode 100644 index 0000000..62bd5a1 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof_vision_skip/main.py @@ -0,0 +1,538 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if not ct.is_in_vision(tile): + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + # If the whole ring is still outside vision, approach the known core + # without caching an assumed-empty home tile. Recompute once nearby. + return self.defendHome if self.defendHome is not None else teamCore + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + self.mapPf.routeConveyor(ct, bestEnd) + return True + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v18_spawn_route_proof_vision_skip/mapPathfinding.py b/bots/exp_v18_spawn_route_proof_vision_skip/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v18_spawn_route_proof_vision_skip/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v19_near_core_finish/README.md b/bots/exp_v19_near_core_finish/README.md new file mode 100644 index 0000000..e9750e7 --- /dev/null +++ b/bots/exp_v19_near_core_finish/README.md @@ -0,0 +1,31 @@ +# v20 route proof + near-core route finish + +Independent child of `bots/exp_v18_spawn_route_proof` (remote submission v19). +Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +An economy builder remembers an interrupted conveyor head only when at most +four Manhattan links remain to the core footprint. It advances the commitment +whenever a segment is built or distance falls, and clears after 24 rounds with +no progress. Longer routes retain the parent's memoryless behavior. This ports +Pantheon's economic route-cost and TTL ideas without map/opponent fingerprints. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v19 baseline | +|---|---:|---:|---:| +| exact v19 route parent | 88/168 (52.4%) | 72-69 | parity gate | +| vs `spar_rush` | 158/168 (94.0%) | 158-3 | 152/168 | +| vs `oogbest-v6` | 142/168 (84.5%) | 138-19 | 148/168 | +| vs `meta-generalist-v1` | 155/168 (92.3%) | 152-12 | 142/168 | + +Across the fixed-source parent gate it collected 193 more titanium per game and +won 15/24 round-1000 games, with +839 collected titanium in those long games. +Sixteen side-swapped Bridge replays increased connected conveyors 1.56 to 2.06 +while reducing disconnected conveyors 23.25 to 4.00, dead ends 2.38 to 0.81, +and post-round-40 conveyors 16.00 to 1.62. Team-A connected routes improved +from 0.00 to 2.12. Deterministic coverage is in +`tests/test_v19_near_core_finish.py`. This candidate is local and inactive. diff --git a/bots/exp_v19_near_core_finish/main.py b/bots/exp_v19_near_core_finish/main.py new file mode 100644 index 0000000..b18d36f --- /dev/null +++ b/bots/exp_v19_near_core_finish/main.py @@ -0,0 +1,624 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v19_near_core_finish/mapPathfinding.py b/bots/exp_v19_near_core_finish/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v19_near_core_finish/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v19_sticky_route/README.md b/bots/exp_v19_sticky_route/README.md new file mode 100644 index 0000000..fcf4e02 --- /dev/null +++ b/bots/exp_v19_sticky_route/README.md @@ -0,0 +1,30 @@ +# v19 route proof + sticky route repair + +Independent child of `bots/exp_v18_spawn_discipline`. Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +An economy builder now remembers the first missing conveyor link after combat +interrupts it. It returns to that head, advances the commitment whenever a +segment is built or distance falls, and clears the target after 24 rounds with +no progress. This ports Pantheon's bounded route-repair/TTL idea without shared +state, opponent fingerprints, or map fingerprints. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | spawn-parent baseline | +|---|---:|---:|---:| +| exact spawn parent | 86/168 (51.2%) | 69-68 | 50% expectation | +| vs `spar_rush` | 152/168 (90.5%) | 151-13 | 151/168 | +| vs `oogbest-v6` | 148/168 (88.1%) | 141-18 | 146/168 | +| vs `meta-generalist-v1` | 142/168 (84.5%) | 142-21 | 146/168 | +| String/Bridge focus | 21/32 (65.6%) | 15-7 | 18/32 prior block | + +Across the 168 parent games it collected 10 more titanium per game, retained 36 +more, and used 1.2 fewer buildings. It won 10/19 round-1000 games. Sixteen +paired Bridge replays preserved connected routes and served harvesters while +reducing total conveyors 18.94 to 14.69, disconnected conveyors 16.81 to 12.56, +and post-round-40 construction 10.00 to 5.94. Deterministic coverage is in +`tests/test_v18_spawn_route_proof.py`. This candidate is local and inactive. diff --git a/bots/exp_v19_sticky_route/main.py b/bots/exp_v19_sticky_route/main.py new file mode 100644 index 0000000..19d4774 --- /dev/null +++ b/bots/exp_v19_sticky_route/main.py @@ -0,0 +1,609 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def commitRoute(self, ct: Controller, target: Position) -> bool: + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v19_sticky_route/mapPathfinding.py b/bots/exp_v19_sticky_route/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v19_sticky_route/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v20_gunner_preempt/README.md b/bots/exp_v20_gunner_preempt/README.md new file mode 100644 index 0000000..e9750e7 --- /dev/null +++ b/bots/exp_v20_gunner_preempt/README.md @@ -0,0 +1,31 @@ +# v20 route proof + near-core route finish + +Independent child of `bots/exp_v18_spawn_route_proof` (remote submission v19). +Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +An economy builder remembers an interrupted conveyor head only when at most +four Manhattan links remain to the core footprint. It advances the commitment +whenever a segment is built or distance falls, and clears after 24 rounds with +no progress. Longer routes retain the parent's memoryless behavior. This ports +Pantheon's economic route-cost and TTL ideas without map/opponent fingerprints. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v19 baseline | +|---|---:|---:|---:| +| exact v19 route parent | 88/168 (52.4%) | 72-69 | parity gate | +| vs `spar_rush` | 158/168 (94.0%) | 158-3 | 152/168 | +| vs `oogbest-v6` | 142/168 (84.5%) | 138-19 | 148/168 | +| vs `meta-generalist-v1` | 155/168 (92.3%) | 152-12 | 142/168 | + +Across the fixed-source parent gate it collected 193 more titanium per game and +won 15/24 round-1000 games, with +839 collected titanium in those long games. +Sixteen side-swapped Bridge replays increased connected conveyors 1.56 to 2.06 +while reducing disconnected conveyors 23.25 to 4.00, dead ends 2.38 to 0.81, +and post-round-40 conveyors 16.00 to 1.62. Team-A connected routes improved +from 0.00 to 2.12. Deterministic coverage is in +`tests/test_v19_near_core_finish.py`. This candidate is local and inactive. diff --git a/bots/exp_v20_gunner_preempt/main.py b/bots/exp_v20_gunner_preempt/main.py new file mode 100644 index 0000000..bd6f5cd --- /dev/null +++ b/bots/exp_v20_gunner_preempt/main.py @@ -0,0 +1,630 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + currentEnemy = False + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + currentEnemy = enemyBuilding or enemyBuilder + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + return + # Direction priority must be evaluated before firing. Previously any + # target already in the barrel short-circuited this scan, so a gunner + # could spend the entire core race on a turret or barrier despite an + # immediately available enemy-core facing. If rotation is deliberately + # unaffordable, retain the useful current shot instead of idling. + if currentEnemy and ct.can_fire(curTarget): + ct.fire(curTarget) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v20_gunner_preempt/mapPathfinding.py b/bots/exp_v20_gunner_preempt/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v20_gunner_preempt/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v20_opportunistic_pressure/README.md b/bots/exp_v20_opportunistic_pressure/README.md new file mode 100644 index 0000000..4683ba7 --- /dev/null +++ b/bots/exp_v20_opportunistic_pressure/README.md @@ -0,0 +1,37 @@ +# v20 near-core finish + opportunistic core pressure + +Independent child of `bots/exp_v18_spawn_route_proof` (remote submission v19). +Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +An economy builder remembers an interrupted conveyor head only when at most +four Manhattan links remain to the core footprint. It advances the commitment +whenever a segment is built or distance falls, and clears after 24 rounds with +no progress. Longer routes retain the parent's memoryless behavior. This ports +Pantheon's economic route-cost and TTL ideas without map/opponent fingerprints. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v19 baseline | +|---|---:|---:|---:| +| exact v19 route parent | 88/168 (52.4%) | 72-69 | parity gate | +| vs `spar_rush` | 158/168 (94.0%) | 158-3 | 152/168 | +| vs `oogbest-v6` | 142/168 (84.5%) | 138-19 | 148/168 | +| vs `meta-generalist-v1` | 155/168 (92.3%) | 152-12 | 142/168 | + +Across the fixed-source parent gate it collected 193 more titanium per game and +won 15/24 round-1000 games, with +839 collected titanium in those long games. +Sixteen side-swapped Bridge replays increased connected conveyors 1.56 to 2.06 +while reducing disconnected conveyors 23.25 to 4.00, dead ends 2.38 to 0.81, +and post-round-40 conveyors 16.00 to 1.62. Team-A connected routes improved +from 0.00 to 2.12. Deterministic coverage is in +`tests/test_v19_near_core_finish.py`. This candidate is local and inactive. + +On top of near-core repair, defenders and economy builders may build a direct +enemy-core gun before counter-battery when a safe legal placement is available +this turn. They never travel or change role for it. This ports Pantheon's +attack-first state ceiling to the production gun-race failure without a map or +opponent fingerprint. diff --git a/bots/exp_v20_opportunistic_pressure/main.py b/bots/exp_v20_opportunistic_pressure/main.py new file mode 100644 index 0000000..5ef7971 --- /dev/null +++ b/bots/exp_v20_opportunistic_pressure/main.py @@ -0,0 +1,661 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + + +def immediatePressureScore(coverage, spot: Position, direction) -> tuple: + return (coverage, spot.x, spot.y, DIRECTIONS.index(direction)) + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + if self.buildImmediateCorePressure(ct): + return + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + if self.buildImmediateCorePressure(ct): + return + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def buildImmediateCorePressure(self, ct: Controller) -> bool: + """Let any role take a safe direct-core gun available this turn.""" + if (self.mapPf.enemyCorePos is None or + ct.get_action_cooldown() != 0 or + ct.get_global_resources() < 80): + return False + enemyCoverage = self.enemyTurretCoverage(ct) + candidates = [] + for spot, direction in self.getAttackableTiles(ct): + if not ct.is_in_vision(spot): + continue + if ct.get_tile_building_id(spot) is not None: + continue + if ct.get_tile_builder_bot_id(spot) is not None: + continue + if ct.get_tile_env(spot) != Environment.EMPTY: + continue + coverage = enemyCoverage.get((spot.x, spot.y), 0) + if coverage > 1 or not ct.can_build_gunner(spot, direction): + continue + candidates.append(( + immediatePressureScore(coverage, spot, direction), + spot, direction)) + if not candidates: + return False + _, spot, direction = min(candidates) + ct.build_gunner(spot, direction) + return True + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v20_opportunistic_pressure/mapPathfinding.py b/bots/exp_v20_opportunistic_pressure/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v20_opportunistic_pressure/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v20_opportunistic_trunk_repair/README.md b/bots/exp_v20_opportunistic_trunk_repair/README.md new file mode 100644 index 0000000..dda9b34 --- /dev/null +++ b/bots/exp_v20_opportunistic_trunk_repair/README.md @@ -0,0 +1,32 @@ +# v21 opportunistic productive-edge repair + +Independent child of inactive v20 `exp_v19_near_core_finish`. It preserves all +v20 combat, spawning, roles, direct siege, and normal conveyor routing. + +Each economy builder remembers only conveyor edges it personally observed as +draining toward the core. If an exact remembered edge later disappears, it is +eligible for repair only when a live upstream conveyor/harvester still feeds +the tile and the remembered output joins a currently proven downstream suffix. +The builder rebuilds it only when `can_build_conveyor` succeeds immediately. +It never walks toward a repair, reserves a role, publishes a claim, or commits +to a distant route. This is the spare-opportunity form of Pantheon's persistent +map/route-repair idea. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v20 baseline / requirement | +|---|---:|---:|---:| +| exact v20 parent | **88/168 (52.4%)** | **75-71** | >=50%, nonnegative kills | +| `spar_rush` | **156/168 (92.9%)** | **156-7** | 158/168 | +| `oogbest-v6` | **139/168 (82.7%)** | **136-22** | 142/168 | +| `meta-generalist-v1` | **153/168 (91.1%)** | **153-10** | 155/168 | + +All family regressions are below five percentage points. After dead claim/travel +code was removed, compact screens were 6/12 parent, 11/12 rush, 10/12 Oogway, +and 10/12 generalist; direct tests and compilation pass. + +Rejected siblings are evidence, not stack components. The walking/TTL repair +finished 249/504 with core kills 214-218 despite a long-game titanium gain; +the round-120 version finished 82/168 with kills 69-71. Gunner preemption +finished 75/168 with kills 61-82. Deterministic coverage is in +`tests/test_v20_opportunistic_trunk_repair.py`. diff --git a/bots/exp_v20_opportunistic_trunk_repair/main.py b/bots/exp_v20_opportunistic_trunk_repair/main.py new file mode 100644 index 0000000..9d1f5fd --- /dev/null +++ b/bots/exp_v20_opportunistic_trunk_repair/main.py @@ -0,0 +1,708 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + # A builder remembers only conveyor edges it personally observed as + # draining. Missing edges are eligible for repair only when their live + # downstream suffix still provably reaches the core. + self.knownConnectedConveyors = {} + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.repairFormerlyConnectedTrunk(ct, myLoc, myTeam): + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, _, convSafe = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if convSafe.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convSafe + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convSafe): + """Return one exact destroyed edge from a formerly draining trunk. + + A missing edge is useful only when it still has a live producer on its + upstream side and its output joins a currently proven suffix. This + excludes abandoned branches and the broad long-route behavior that + already lost its economy gate. + """ + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + candidates = [] + for key, (pos, direction) in self.knownConnectedConveyors.items(): + if key in convDirs or not ct.is_in_vision(pos): + continue + if ct.get_tile_building_id(pos) is not None: + continue + output = pos.add(direction) + outputKey = output.x * 32 + output.y + downstreamLive = output in coreTiles or ( + outputKey in convDirs and convSafe.get(outputKey, False)) + if not downstreamLive: + continue + + upstreamCount = 0 + loadedUpstream = 0 + for data in convDirs.values(): + if data[0].add(data[1]) == pos: + upstreamCount += 1 + loadedUpstream += 1 if data[2] else 0 + for d in CARDINALS: + source = pos.add(d) + if not (0 <= source.x < self.mapW and 0 <= source.y < self.mapH): + continue + if not ct.is_in_vision(source): + continue + sourceId = ct.get_tile_building_id(source) + if (sourceId is not None and ct.get_team(sourceId) == myTeam and + ct.get_entity_type(sourceId) == EntityType.HARVESTER): + upstreamCount += 1 + if upstreamCount == 0: + continue + score = (-loadedUpstream, -upstreamCount, + myLoc.distance_squared(pos), coreFootprintManhattan(pos, core), key) + candidates.append((score, pos, direction)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1], candidates[0][2] + + def repairFormerlyConnectedTrunk(self, ct: Controller, myLoc, myTeam) -> bool: + if self.mapPf.teamCore is None: + return False + convDirs, convSafe = self.observeConnectedConveyors(ct, myTeam) + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convSafe) + if candidate is None: + return False + target, direction = candidate + # Never walk, reserve a role, or publish a claim for repair. Rebuild the + # exact productive edge only when this builder can do it immediately; + # otherwise normal routing/economy continues unchanged this turn. + if ct.can_build_conveyor(target, direction): + ct.build_conveyor(target, direction) + self.knownConnectedConveyors[target.x * 32 + target.y] = ( + target, direction) + return True + return False + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v20_opportunistic_trunk_repair/mapPathfinding.py b/bots/exp_v20_opportunistic_trunk_repair/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v20_opportunistic_trunk_repair/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v20_trunk_repair/README.md b/bots/exp_v20_trunk_repair/README.md new file mode 100644 index 0000000..e9750e7 --- /dev/null +++ b/bots/exp_v20_trunk_repair/README.md @@ -0,0 +1,31 @@ +# v20 route proof + near-core route finish + +Independent child of `bots/exp_v18_spawn_route_proof` (remote submission v19). +Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +An economy builder remembers an interrupted conveyor head only when at most +four Manhattan links remain to the core footprint. It advances the commitment +whenever a segment is built or distance falls, and clears after 24 rounds with +no progress. Longer routes retain the parent's memoryless behavior. This ports +Pantheon's economic route-cost and TTL ideas without map/opponent fingerprints. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v19 baseline | +|---|---:|---:|---:| +| exact v19 route parent | 88/168 (52.4%) | 72-69 | parity gate | +| vs `spar_rush` | 158/168 (94.0%) | 158-3 | 152/168 | +| vs `oogbest-v6` | 142/168 (84.5%) | 138-19 | 148/168 | +| vs `meta-generalist-v1` | 155/168 (92.3%) | 152-12 | 142/168 | + +Across the fixed-source parent gate it collected 193 more titanium per game and +won 15/24 round-1000 games, with +839 collected titanium in those long games. +Sixteen side-swapped Bridge replays increased connected conveyors 1.56 to 2.06 +while reducing disconnected conveyors 23.25 to 4.00, dead ends 2.38 to 0.81, +and post-round-40 conveyors 16.00 to 1.62. Team-A connected routes improved +from 0.00 to 2.12. Deterministic coverage is in +`tests/test_v19_near_core_finish.py`. This candidate is local and inactive. diff --git a/bots/exp_v20_trunk_repair/main.py b/bots/exp_v20_trunk_repair/main.py new file mode 100644 index 0000000..0e1a7d0 --- /dev/null +++ b/bots/exp_v20_trunk_repair/main.py @@ -0,0 +1,813 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 +REPAIR_TARGET_SLOT = 9 +REPAIR_OWNER_ROUND_SLOT = 10 +REPAIR_CLAIM_TTL = 6 +REPAIR_STALL_ROUNDS = 16 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + + +def packRepairTarget(pos: Position, direction: Direction) -> int: + """Pack an exact missing conveyor edge; zero remains "no claim".""" + return ((((pos.x << 5) | pos.y) << 2) | CARDINALS.index(direction)) + 1 + + +def unpackRepairTarget(value: int): + if value == 0: + return None + packed = value - 1 + return Position((packed >> 7) & 31, (packed >> 2) & 31), CARDINALS[packed & 3] + + +def packRepairOwnerRound(ownerId: int, roundNumber: int) -> int: + return ((roundNumber & 0xFFFF) << 16) | ((ownerId + 1) & 0xFFFF) + + +def unpackRepairOwnerRound(value: int): + if value == 0: + return None + return (value & 0xFFFF) - 1, (value >> 16) & 0xFFFF + + +def repairClaimActive(value: int, currentRound: int) -> bool: + decoded = unpackRepairOwnerRound(value) + return decoded is not None and 0 <= currentRound - decoded[1] <= REPAIR_CLAIM_TTL + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + # A builder remembers only conveyor edges it personally observed as + # draining. Missing edges are eligible for repair only when their live + # downstream suffix still provably reaches the core. + self.knownConnectedConveyors = {} + self.repairTarget = None + self.repairDirection = None + self.repairBestDistance = None + self.repairLastProgressRound = 0 + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.repairFormerlyConnectedTrunk(ct, myLoc, myTeam): + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def clearRepairClaim(self, ct: Controller, publish=True): + if publish: + owner = unpackRepairOwnerRound(ct.read_store(REPAIR_OWNER_ROUND_SLOT)) + if owner is None or owner[0] == ct.get_id(): + ct.write_store(REPAIR_TARGET_SLOT, 0) + ct.write_store(REPAIR_OWNER_ROUND_SLOT, 0) + self.repairTarget = None + self.repairDirection = None + self.repairBestDistance = None + self.repairLastProgressRound = 0 + + def observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, _, convSafe = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if convSafe.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convSafe + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convSafe): + """Return one exact destroyed edge from a formerly draining trunk. + + A missing edge is useful only when it still has a live producer on its + upstream side and its output joins a currently proven suffix. This + excludes abandoned branches and the broad long-route behavior that + already lost its economy gate. + """ + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + candidates = [] + for key, (pos, direction) in self.knownConnectedConveyors.items(): + if key in convDirs or not ct.is_in_vision(pos): + continue + if ct.get_tile_building_id(pos) is not None: + continue + output = pos.add(direction) + outputKey = output.x * 32 + output.y + downstreamLive = output in coreTiles or ( + outputKey in convDirs and convSafe.get(outputKey, False)) + if not downstreamLive: + continue + + upstreamCount = 0 + loadedUpstream = 0 + for data in convDirs.values(): + if data[0].add(data[1]) == pos: + upstreamCount += 1 + loadedUpstream += 1 if data[2] else 0 + for d in CARDINALS: + source = pos.add(d) + if not (0 <= source.x < self.mapW and 0 <= source.y < self.mapH): + continue + if not ct.is_in_vision(source): + continue + sourceId = ct.get_tile_building_id(source) + if (sourceId is not None and ct.get_team(sourceId) == myTeam and + ct.get_entity_type(sourceId) == EntityType.HARVESTER): + upstreamCount += 1 + if upstreamCount == 0: + continue + score = (-loadedUpstream, -upstreamCount, + myLoc.distance_squared(pos), coreFootprintManhattan(pos, core), key) + candidates.append((score, pos, direction)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1], candidates[0][2] + + def repairTargetStillValid(self, ct: Controller, myTeam, convDirs, convSafe): + target, direction = self.repairTarget, self.repairDirection + if target is None or direction is None: + return False + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if targetId is not None: + return (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR and + ct.get_direction(targetId) == direction) + output = target.add(direction) + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + outputKey = output.x * 32 + output.y + return output in coreTiles or (outputKey in convDirs and convSafe.get(outputKey, False)) + + def repairFormerlyConnectedTrunk(self, ct: Controller, myLoc, myTeam) -> bool: + if self.mapPf.teamCore is None: + return False + currentRound = ct.get_current_round() + convDirs, convSafe = self.observeConnectedConveyors(ct, myTeam) + sharedTarget = unpackRepairTarget(ct.read_store(REPAIR_TARGET_SLOT)) + sharedOwner = unpackRepairOwnerRound(ct.read_store(REPAIR_OWNER_ROUND_SLOT)) + active = repairClaimActive(ct.read_store(REPAIR_OWNER_ROUND_SLOT), currentRound) + + if active and sharedOwner[0] != ct.get_id(): + if self.repairTarget is not None: + self.clearRepairClaim(ct, publish=False) + return False + + if self.repairTarget is None: + if active and sharedOwner[0] == ct.get_id() and sharedTarget is not None: + self.repairTarget, self.repairDirection = sharedTarget + elif not active: + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convSafe) + if candidate is None: + return False + self.repairTarget, self.repairDirection = candidate + else: + return False + self.repairBestDistance = None + self.repairLastProgressRound = currentRound + + if not self.repairTargetStillValid(ct, myTeam, convDirs, convSafe): + self.clearRepairClaim(ct) + return False + + target = self.repairTarget + if ct.is_in_vision(target): + targetId = ct.get_tile_building_id(target) + if targetId is not None: + # Another builder completed the claimed edge. + self.clearRepairClaim(ct) + return True + + distance = myLoc.distance_squared(target) + if self.repairBestDistance is None or distance < self.repairBestDistance: + self.repairBestDistance = distance + self.repairLastProgressRound = currentRound + elif currentRound - self.repairLastProgressRound >= REPAIR_STALL_ROUNDS: + self.clearRepairClaim(ct) + return False + + ct.write_store(REPAIR_TARGET_SLOT, packRepairTarget(target, self.repairDirection)) + ct.write_store(REPAIR_OWNER_ROUND_SLOT, packRepairOwnerRound(ct.get_id(), currentRound)) + if ct.can_build_conveyor(target, self.repairDirection): + ct.build_conveyor(target, self.repairDirection) + self.knownConnectedConveyors[target.x * 32 + target.y] = ( + target, self.repairDirection) + self.clearRepairClaim(ct) + return True + self.mapPf.moveTo(ct, target) + return True + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v20_trunk_repair/mapPathfinding.py b/bots/exp_v20_trunk_repair/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v20_trunk_repair/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v20_trunk_repair_late/README.md b/bots/exp_v20_trunk_repair_late/README.md new file mode 100644 index 0000000..e9750e7 --- /dev/null +++ b/bots/exp_v20_trunk_repair_late/README.md @@ -0,0 +1,31 @@ +# v20 route proof + near-core route finish + +Independent child of `bots/exp_v18_spawn_route_proof` (remote submission v19). +Conveyor routing no +longer treats every visible non-loop conveyor as a completed trunk. It merges +only into a chain visibly reaching the core, or a chain that leaves vision +after every observed edge makes strict Manhattan progress toward the core. +Combat, roles, spawning, and the round-40 economy transition are unchanged. + +An economy builder remembers an interrupted conveyor head only when at most +four Manhattan links remain to the core footprint. It advances the commitment +whenever a segment is built or distance falls, and clears after 24 rounds with +no progress. Longer routes retain the parent's memoryless behavior. This ports +Pantheon's economic route-cost and TTL ideas without map/opponent fingerprints. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v19 baseline | +|---|---:|---:|---:| +| exact v19 route parent | 88/168 (52.4%) | 72-69 | parity gate | +| vs `spar_rush` | 158/168 (94.0%) | 158-3 | 152/168 | +| vs `oogbest-v6` | 142/168 (84.5%) | 138-19 | 148/168 | +| vs `meta-generalist-v1` | 155/168 (92.3%) | 152-12 | 142/168 | + +Across the fixed-source parent gate it collected 193 more titanium per game and +won 15/24 round-1000 games, with +839 collected titanium in those long games. +Sixteen side-swapped Bridge replays increased connected conveyors 1.56 to 2.06 +while reducing disconnected conveyors 23.25 to 4.00, dead ends 2.38 to 0.81, +and post-round-40 conveyors 16.00 to 1.62. Team-A connected routes improved +from 0.00 to 2.12. Deterministic coverage is in +`tests/test_v19_near_core_finish.py`. This candidate is local and inactive. diff --git a/bots/exp_v20_trunk_repair_late/main.py b/bots/exp_v20_trunk_repair_late/main.py new file mode 100644 index 0000000..69dd57b --- /dev/null +++ b/bots/exp_v20_trunk_repair_late/main.py @@ -0,0 +1,816 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 +REPAIR_TARGET_SLOT = 9 +REPAIR_OWNER_ROUND_SLOT = 10 +REPAIR_CLAIM_TTL = 6 +REPAIR_STALL_ROUNDS = 16 +REPAIR_MIN_ROUND = 120 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + + +def packRepairTarget(pos: Position, direction: Direction) -> int: + """Pack an exact missing conveyor edge; zero remains "no claim".""" + return ((((pos.x << 5) | pos.y) << 2) | CARDINALS.index(direction)) + 1 + + +def unpackRepairTarget(value: int): + if value == 0: + return None + packed = value - 1 + return Position((packed >> 7) & 31, (packed >> 2) & 31), CARDINALS[packed & 3] + + +def packRepairOwnerRound(ownerId: int, roundNumber: int) -> int: + return ((roundNumber & 0xFFFF) << 16) | ((ownerId + 1) & 0xFFFF) + + +def unpackRepairOwnerRound(value: int): + if value == 0: + return None + return (value & 0xFFFF) - 1, (value >> 16) & 0xFFFF + + +def repairClaimActive(value: int, currentRound: int) -> bool: + decoded = unpackRepairOwnerRound(value) + return decoded is not None and 0 <= currentRound - decoded[1] <= REPAIR_CLAIM_TTL + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + # A builder remembers only conveyor edges it personally observed as + # draining. Missing edges are eligible for repair only when their live + # downstream suffix still provably reaches the core. + self.knownConnectedConveyors = {} + self.repairTarget = None + self.repairDirection = None + self.repairBestDistance = None + self.repairLastProgressRound = 0 + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + if enemyGunners: # all covered - heal + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.repairFormerlyConnectedTrunk(ct, myLoc, myTeam): + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def clearRepairClaim(self, ct: Controller, publish=True): + if publish: + owner = unpackRepairOwnerRound(ct.read_store(REPAIR_OWNER_ROUND_SLOT)) + if owner is None or owner[0] == ct.get_id(): + ct.write_store(REPAIR_TARGET_SLOT, 0) + ct.write_store(REPAIR_OWNER_ROUND_SLOT, 0) + self.repairTarget = None + self.repairDirection = None + self.repairBestDistance = None + self.repairLastProgressRound = 0 + + def observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, _, convSafe = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if convSafe.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convSafe + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convSafe): + """Return one exact destroyed edge from a formerly draining trunk. + + A missing edge is useful only when it still has a live producer on its + upstream side and its output joins a currently proven suffix. This + excludes abandoned branches and the broad long-route behavior that + already lost its economy gate. + """ + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + candidates = [] + for key, (pos, direction) in self.knownConnectedConveyors.items(): + if key in convDirs or not ct.is_in_vision(pos): + continue + if ct.get_tile_building_id(pos) is not None: + continue + output = pos.add(direction) + outputKey = output.x * 32 + output.y + downstreamLive = output in coreTiles or ( + outputKey in convDirs and convSafe.get(outputKey, False)) + if not downstreamLive: + continue + + upstreamCount = 0 + loadedUpstream = 0 + for data in convDirs.values(): + if data[0].add(data[1]) == pos: + upstreamCount += 1 + loadedUpstream += 1 if data[2] else 0 + for d in CARDINALS: + source = pos.add(d) + if not (0 <= source.x < self.mapW and 0 <= source.y < self.mapH): + continue + if not ct.is_in_vision(source): + continue + sourceId = ct.get_tile_building_id(source) + if (sourceId is not None and ct.get_team(sourceId) == myTeam and + ct.get_entity_type(sourceId) == EntityType.HARVESTER): + upstreamCount += 1 + if upstreamCount == 0: + continue + score = (-loadedUpstream, -upstreamCount, + myLoc.distance_squared(pos), coreFootprintManhattan(pos, core), key) + candidates.append((score, pos, direction)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1], candidates[0][2] + + def repairTargetStillValid(self, ct: Controller, myTeam, convDirs, convSafe): + target, direction = self.repairTarget, self.repairDirection + if target is None or direction is None: + return False + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if targetId is not None: + return (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR and + ct.get_direction(targetId) == direction) + output = target.add(direction) + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + outputKey = output.x * 32 + output.y + return output in coreTiles or (outputKey in convDirs and convSafe.get(outputKey, False)) + + def repairFormerlyConnectedTrunk(self, ct: Controller, myLoc, myTeam) -> bool: + if self.mapPf.teamCore is None: + return False + currentRound = ct.get_current_round() + convDirs, convSafe = self.observeConnectedConveyors(ct, myTeam) + if currentRound < REPAIR_MIN_ROUND: + return False + sharedTarget = unpackRepairTarget(ct.read_store(REPAIR_TARGET_SLOT)) + sharedOwner = unpackRepairOwnerRound(ct.read_store(REPAIR_OWNER_ROUND_SLOT)) + active = repairClaimActive(ct.read_store(REPAIR_OWNER_ROUND_SLOT), currentRound) + + if active and sharedOwner[0] != ct.get_id(): + if self.repairTarget is not None: + self.clearRepairClaim(ct, publish=False) + return False + + if self.repairTarget is None: + if active and sharedOwner[0] == ct.get_id() and sharedTarget is not None: + self.repairTarget, self.repairDirection = sharedTarget + elif not active: + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convSafe) + if candidate is None: + return False + self.repairTarget, self.repairDirection = candidate + else: + return False + self.repairBestDistance = None + self.repairLastProgressRound = currentRound + + if not self.repairTargetStillValid(ct, myTeam, convDirs, convSafe): + self.clearRepairClaim(ct) + return False + + target = self.repairTarget + if ct.is_in_vision(target): + targetId = ct.get_tile_building_id(target) + if targetId is not None: + # Another builder completed the claimed edge. + self.clearRepairClaim(ct) + return True + + distance = myLoc.distance_squared(target) + if self.repairBestDistance is None or distance < self.repairBestDistance: + self.repairBestDistance = distance + self.repairLastProgressRound = currentRound + elif currentRound - self.repairLastProgressRound >= REPAIR_STALL_ROUNDS: + self.clearRepairClaim(ct) + return False + + ct.write_store(REPAIR_TARGET_SLOT, packRepairTarget(target, self.repairDirection)) + ct.write_store(REPAIR_OWNER_ROUND_SLOT, packRepairOwnerRound(ct.get_id(), currentRound)) + if ct.can_build_conveyor(target, self.repairDirection): + ct.build_conveyor(target, self.repairDirection) + self.knownConnectedConveyors[target.x * 32 + target.y] = ( + target, self.repairDirection) + self.clearRepairClaim(ct) + return True + self.mapPf.moveTo(ct, target) + return True + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v20_trunk_repair_late/mapPathfinding.py b/bots/exp_v20_trunk_repair_late/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v20_trunk_repair_late/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v21_eco_release/README.md b/bots/exp_v21_eco_release/README.md new file mode 100644 index 0000000..dda9b34 --- /dev/null +++ b/bots/exp_v21_eco_release/README.md @@ -0,0 +1,32 @@ +# v21 opportunistic productive-edge repair + +Independent child of inactive v20 `exp_v19_near_core_finish`. It preserves all +v20 combat, spawning, roles, direct siege, and normal conveyor routing. + +Each economy builder remembers only conveyor edges it personally observed as +draining toward the core. If an exact remembered edge later disappears, it is +eligible for repair only when a live upstream conveyor/harvester still feeds +the tile and the remembered output joins a currently proven downstream suffix. +The builder rebuilds it only when `can_build_conveyor` succeeds immediately. +It never walks toward a repair, reserves a role, publishes a claim, or commits +to a distant route. This is the spare-opportunity form of Pantheon's persistent +map/route-repair idea. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v20 baseline / requirement | +|---|---:|---:|---:| +| exact v20 parent | **88/168 (52.4%)** | **75-71** | >=50%, nonnegative kills | +| `spar_rush` | **156/168 (92.9%)** | **156-7** | 158/168 | +| `oogbest-v6` | **139/168 (82.7%)** | **136-22** | 142/168 | +| `meta-generalist-v1` | **153/168 (91.1%)** | **153-10** | 155/168 | + +All family regressions are below five percentage points. After dead claim/travel +code was removed, compact screens were 6/12 parent, 11/12 rush, 10/12 Oogway, +and 10/12 generalist; direct tests and compilation pass. + +Rejected siblings are evidence, not stack components. The walking/TTL repair +finished 249/504 with core kills 214-218 despite a long-game titanium gain; +the round-120 version finished 82/168 with kills 69-71. Gunner preemption +finished 75/168 with kills 61-82. Deterministic coverage is in +`tests/test_v20_opportunistic_trunk_repair.py`. diff --git a/bots/exp_v21_eco_release/main.py b/bots/exp_v21_eco_release/main.py new file mode 100644 index 0000000..c3bc196 --- /dev/null +++ b/bots/exp_v21_eco_release/main.py @@ -0,0 +1,710 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + # A builder remembers only conveyor edges it personally observed as + # draining. Missing edges are eligible for repair only when their live + # downstream suffix still provably reaches the core. + self.knownConnectedConveyors = {} + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + # Covered threats belong to the fixed defender roles. The inherited + # unconditional heal/return recalled every economy builder even when + # the core was full or healing was unavailable, permanently deleting + # the economy under harmless sustained pressure. Economy still counters + # every uncovered gun above; once coverage exists it resumes its job. + if self.repairFormerlyConnectedTrunk(ct, myLoc, myTeam): + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, _, convSafe = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if convSafe.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convSafe + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convSafe): + """Return one exact destroyed edge from a formerly draining trunk. + + A missing edge is useful only when it still has a live producer on its + upstream side and its output joins a currently proven suffix. This + excludes abandoned branches and the broad long-route behavior that + already lost its economy gate. + """ + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + candidates = [] + for key, (pos, direction) in self.knownConnectedConveyors.items(): + if key in convDirs or not ct.is_in_vision(pos): + continue + if ct.get_tile_building_id(pos) is not None: + continue + output = pos.add(direction) + outputKey = output.x * 32 + output.y + downstreamLive = output in coreTiles or ( + outputKey in convDirs and convSafe.get(outputKey, False)) + if not downstreamLive: + continue + + upstreamCount = 0 + loadedUpstream = 0 + for data in convDirs.values(): + if data[0].add(data[1]) == pos: + upstreamCount += 1 + loadedUpstream += 1 if data[2] else 0 + for d in CARDINALS: + source = pos.add(d) + if not (0 <= source.x < self.mapW and 0 <= source.y < self.mapH): + continue + if not ct.is_in_vision(source): + continue + sourceId = ct.get_tile_building_id(source) + if (sourceId is not None and ct.get_team(sourceId) == myTeam and + ct.get_entity_type(sourceId) == EntityType.HARVESTER): + upstreamCount += 1 + if upstreamCount == 0: + continue + score = (-loadedUpstream, -upstreamCount, + myLoc.distance_squared(pos), coreFootprintManhattan(pos, core), key) + candidates.append((score, pos, direction)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1], candidates[0][2] + + def repairFormerlyConnectedTrunk(self, ct: Controller, myLoc, myTeam) -> bool: + if self.mapPf.teamCore is None: + return False + convDirs, convSafe = self.observeConnectedConveyors(ct, myTeam) + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convSafe) + if candidate is None: + return False + target, direction = candidate + # Never walk, reserve a role, or publish a claim for repair. Rebuild the + # exact productive edge only when this builder can do it immediately; + # otherwise normal routing/economy continues unchanged this turn. + if ct.can_build_conveyor(target, direction): + ct.build_conveyor(target, direction) + self.knownConnectedConveyors[target.x * 32 + target.y] = ( + target, direction) + return True + return False + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v21_eco_release/mapPathfinding.py b/bots/exp_v21_eco_release/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v21_eco_release/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_v21_eco_release_only_damaged/README.md b/bots/exp_v21_eco_release_only_damaged/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v21_eco_release_only_damaged/README.md @@ -0,0 +1,42 @@ +# v22 productive-edge repair + damaged-core economy ownership + +Child of inactive submission v21 `exp_v20_opportunistic_trunk_repair`. +It preserves v21's exact spare-opportunity repair of previously productive +conveyor edges. + +The additional change fixes an inherited role-ownership bug. Economy builders +still counter every visible uncovered enemy gun. Once all visible guns are +covered, however, they join healing only when they can see that the friendly +core is actually damaged. A full or out-of-sight core releases them back to +routing and harvesting. Fixed defender roles continue countering and healing +unchanged. This prevents `healCore` from recalling all economy builders after a +failed heal while retaining emergency healing during real damage. + +Fresh production 2.3.3 gates (21 maps, four seeds, both sides): + +| Gate | Result | Core kills | v21 baseline | +|---|---:|---:|---:| +| exact v21 parent | **90/168 (53.6%)** | **73-73** | parity requirement | +| `spar_rush` | **160/168 (95.2%)** | **157-5** | 156/168 | +| `oogbest-v6` | **147/168 (87.5%)** | **140-16** | 139/168 | +| `meta-generalist-v1` | **153/168 (91.1%)** | **152-9** | 153/168 | + +The broader release of all covered-threat economy builders was rejected at +83/168 with core kills 73-79. The visible-damaged-core condition is therefore +part of the mechanism, not a tuning detail. Deterministic coverage is in +`tests/test_v21_eco_release_only_damaged.py`. + +Submission v22 (`5861c003-13a8-48c4-bc42-fe01445c6245`) completed ten unrated +series at **30-20**, estimated core kills **30-19**. Against unchanged opponent +versions it scored 29-11: CtrlAltDefeat 10-0, Orizon 7-3, team lazy 6-4, and +SmartFridge 6-4. Updated Pantheon v14 scored 9-1 against v22 and remains a new +pressure-meta problem rather than an optimization target. Across all 50 games +v22 averaged 10.36 connected and 1.32 disconnected conveyors. The replay- +confirmed team-lazy String failure changed from no economy and a round-1000 +loss in v21 to five harvesters and a turn-829 core win in v22. Replays are in +`C:/Users/subodh/Downloads/fcode-gate-artifacts/ladder-v22-20260803/`. + +A pinned v18 control then scored 0-5 against Pantheon v14 on String, Pinch, +Bridge, Atoll, and Sprint, all by core destruction. V22 was 1-4 on its matching +five-map series, so the Pantheon result is a new opponent-version weakness, not +a demonstrated v22 regression. diff --git a/bots/exp_v21_eco_release_only_damaged/main.py b/bots/exp_v21_eco_release_only_damaged/main.py new file mode 100644 index 0000000..a5c2a2d --- /dev/null +++ b/bots/exp_v21_eco_release_only_damaged/main.py @@ -0,0 +1,717 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; run() is called once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +# If the core goes below 400 health, and sees a gunner attacking it, it will immediately send a 'recall' order in the next unused slot, where the other bots will walk back to team core, scored based on their distance to it. Synergizes well with OogwayTestExplore +# and spend up titanium to get to 50 ammo, stopping when it has 20 titanium left + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] +CORE_BUILDER_CONGESTION_LIMIT = 6 +ROUTE_STALL_ROUNDS = 24 +ROUTE_COMMIT_MAX_LINKS = 4 + +from mapPathfinding import * + +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 teamCore loc (for gunners), slot 8 symmetry (mapPathfinding) + + +def extraSpawnAllowed(numSpawned, globalTitanium, nearbyFriendlyBuilders): + """Preserve the four-role opening, then stop feeding a crowded core.""" + return (numSpawned < 4 or + (globalTitanium > 360 and + nearbyFriendlyBuilders < CORE_BUILDER_CONGESTION_LIMIT)) + + +def coreFootprintManhattan(pos: Position, core: Position) -> int: + return min( + abs(pos.x - (core.x + dx)) + abs(pos.y - (core.y + dy)) + for dx in (0, 1) for dy in (0, 1) + ) + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.numSpawned = 0 + self.mapW = None + self.mapH = None + self.defendHome = None + self.visitedCenter = False + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + # A builder remembers only conveyor edges it personally observed as + # draining. Missing edges are eligible for repair only when their live + # downstream suffix still provably reaches the core. + self.knownConnectedConveyors = {} + + def distToCore(self, ct: Controller, pos: Position): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + coreCorners.sort(key=lambda coreCorner: coreCorner.distance_squared(pos)) + return coreCorners[0].distance_squared(pos) + + def runCore(self, ct: Controller) -> None: + myLoc = ct.get_position() + + nearbyOres = [] + for tile in ct.get_nearby_tiles(): + if ct.get_tile_env(tile) == Environment.ORE_TITANIUM: + nearbyOres.append(tile) + nearbyOres.sort(key=lambda ore: self.distToCore(ct, ore)) + + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + myTeam = ct.get_team() + nearbyFriendlyBuilders = sum( + 1 for unitId in ct.get_nearby_units() + if (ct.get_team(unitId) == myTeam and + ct.get_entity_type(unitId) == EntityType.BUILDER_BOT) + ) + if extraSpawnAllowed(self.numSpawned, globalTitanium, nearbyFriendlyBuilders): + spawnableTiles = [] + for tile in ct.get_nearby_tiles(): + if ct.can_spawn(tile): + spawnableTiles.append(tile) + if spawnableTiles: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if self.numSpawned % 2 == 0: # attacking bots + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(mapCenter)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + elif self.numSpawned % 2 == 1: # eco/defense bot + sortTarget = nearbyOres[0] if nearbyOres else mapCenter + spawnableTiles.sort(key=lambda spawnableTile: spawnableTile.distance_squared(sortTarget)) + closestTile = spawnableTiles[0] + ct.spawn_builder(closestTile) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned) # used so bots know their roles + ct.write_store(7, myLoc.x * 32 + myLoc.y) + convertAmount = min(16 - globalAmmo, globalTitanium - 28) + if convertAmount > 0 and ct.can_convert_ammo(convertAmount): + ct.convert_ammo(convertAmount) + + def run(self, ct: Controller) -> None: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self.mapPf.myNum % 2 == 1: # attacking + # pathfind to enemy core. if it isnt known pathfind to center of map. + # once the enemy core is known, calculate all possible tiles that are empty and you can place a gunner on such that it would attack the enemy core + # score them by their distance, and the number of enemy gunners attacking those tiles (if it has more than 1, dont count it) + # pathfind to the one with the highest score, and place a gunner there + # if there are no such spots, the create a gunner to target enemy gunners, scoring based on distance to enemy core, and the best valid spots. + # valid spots are scored based on their proximity to the builder bot, as number of enemy gunners attacking them (also limited to one.) + # continue, until your titanium drops below 80 + self.runAttack(ct) + elif self.mapPf.myNum == 4 or self.mapPf.myNum == 6: # defending + # always stand on a tile adjacent to the core, and try to be as close to the center of the map as possible + # (bot 4 stays as close to the center as possible, bot 6 as far as possible) + # builds gunners to counter enemy gunners/sentinels only (never enemy builders) - otherwise heals the core + self.runDefend(ct) + else: # economy + # go around the map, routing conveyors back to the core. + # routing/harvesting is priority number 2, first priority, is if you ever see and enemy gunner, either attack it or heal it based on if that gunner is being attacked or not. + self.runEco(ct) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + mapCenter = Position(self.mapW // 2, self.mapH // 2) + if not self.visitedCenter: + if ct.get_current_round() > 20 or myLoc.distance_squared(mapCenter) < 12: + self.visitedCenter = True + else: + self.mapPf.moveTo(ct, mapCenter) + return + candidates = list(self.mapPf.allEnemyCore.values()) + if candidates: + target = candidates[(self.mapPf.myNum // 2) % len(candidates)] + self.mapPf.moveTo(ct, target) + else: + corners = [Position(1, 1), Position(self.mapW - 2, 1), + Position(1, self.mapH - 2), Position(self.mapW - 2, self.mapH - 2)] + target = corners[(self.mapPf.myNum // 2) % len(corners)] + self.mapPf.moveTo(ct, target) + return + else: + if ct.get_global_resources() >= 80: + gunnerStuff = self.findGunnerSpot(ct) + if gunnerStuff: + gunnerSpot, gunnerDir = gunnerStuff + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return + myDist = myLoc.distance_squared(gunnerSpot) + if myDist < 1: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + elif myDist > 1: + self.mapPf.moveTo(ct, gunnerSpot) + return + self.mapPf.moveTo(ct, self.mapPf.enemyCorePos) + + def findGunnerSpot(self, ct): + enemyCoverage = self.enemyTurretCoverage(ct) + coreAttackers = self.getAttackableTiles(ct) + bestAttacker = None + bestScore = None + myLoc = ct.get_position() + enemyCore = self.mapPf.enemyCorePos + for spotPos, spotDir in coreAttackers: + if not ct.is_in_vision(spotPos): + continue + if ct.get_tile_building_id(spotPos) is not None: + continue + if ct.get_tile_env(spotPos) != Environment.EMPTY: + continue + seatCov = enemyCoverage.get((spotPos.x, spotPos.y), 0) + if seatCov > 1: + continue + score = (seatCov, myLoc.distance_squared(spotPos), spotPos.distance_squared(enemyCore)) + if bestScore is None or score < bestScore: + bestScore = score + bestAttacker = (spotPos, spotDir) + return bestAttacker + + def getAttackableTiles(self, ct): + enemyCore = self.mapPf.enemyCorePos + coreCorners = [ + enemyCore, enemyCore.add(Direction.EAST), enemyCore.add(Direction.SOUTH), + enemyCore.add(Direction.SOUTH).add(Direction.EAST)] + coreAttackers = set() + for corner in coreCorners: + for spot in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, True): + coreAttackers.add((spot[0], spot[1])) + return coreAttackers + + def enemyTurretCoverage(self, ct: Controller) -> dict: + enemyCoverage = {} + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + bType = ct.get_entity_type(b) + if ct.get_team(b) == myTeam or bType not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + bPos = ct.get_position(b) + curDir = ct.get_direction(b) + facings = DIRECTIONS if bType == EntityType.GUNNER else [curDir] + for d in facings: + weight = 1.0 if d == curDir else 0.5 + dx, dy = d.delta() + maxK = 3 if d in CARDINALS else 2 + x, y = bPos.x, bPos.y + for _ in range(maxK): + x += dx + y += dy + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + break + enemyCoverage[(x, y)] = enemyCoverage.get((x, y), 0) + weight + tilePos = Position(x, y) + if not ct.is_in_vision(tilePos): + break + if ct.get_tile_env(tilePos) == Environment.WALL: + break + if ct.get_tile_building_id(tilePos) is not None: + break + if ct.get_tile_builder_bot_id(tilePos) is not None: + break + return enemyCoverage + + def coreThreatSpots(self, ct: Controller): + compact = ct.read_store(7) + teamCore = Position(compact // 32, compact % 32) + corners = [teamCore, teamCore.add(Direction.EAST), teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + spots = set() + for corner in corners: + for spotPos, spotDir in self.mapPf.gunnerSpots(corner, self.mapW, self.mapH, blocked=False): + spots.add((spotPos.x, spotPos.y, spotDir)) + return spots + + def runGunner(self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemyBuilding = targetId is not None and ct.get_team(targetId) != myTeam + enemyBuilder = bbId is not None and ct.get_team(bbId) != myTeam + if enemyBuilding or enemyBuilder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + threatSpots = self.coreThreatSpots(ct) + directionScores = [] + bestDir = myDir + bestIsCoreDefense = False + for directionIndex, d in enumerate(DIRECTIONS): + coreHits = 0 + coreThreatHits = 0 + gunnerHits = 0 + otherHits = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType == EntityType.CORE: + coreHits += 1 + elif tType in [EntityType.GUNNER, EntityType.SENTINEL]: + gunnerHits += 1 + if tType == EntityType.GUNNER and (tile.x, tile.y, ct.get_direction(tileId)) in threatSpots: + coreThreatHits += 1 + else: + otherHits += 1 + # Keep core pressure first, then preserve guns that are actively + # stopping a core shot. Prefer the current facing on exact ties so + # equivalent targets do not burn 10 Ti rotating back and forth. + score = (coreHits, coreThreatHits, gunnerHits, otherHits) + directionScores.append(( + score, + 1 if d == myDir else 0, + -directionIndex, + d, + coreThreatHits > 0, + )) + if directionScores: + _, _, _, bestDir, bestIsCoreDefense = max(directionScores) + if bestDir != myDir: + floor = 25 if bestIsCoreDefense else 60 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam and ct.get_entity_type(b) in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + self.healCore(ct) + + def getDefendHome(self, ct: Controller): + if self.defendHome is not None: + return self.defendHome + teamCore = self.mapPf.teamCore + if teamCore is None: + return None + center = Position(self.mapW // 2, self.mapH // 2) + covered = self.coveredTiles(ct) + candidates = [] + fallback = [] + for dx in range(-1, 3): + for dy in range(-1, 3): + if 0 <= dx <= 1 and 0 <= dy <= 1: # skip the 2x2 core itself + continue + tile = Position(teamCore.x + dx, teamCore.y + dy) + if not (0 <= tile.x < self.mapW and 0 <= tile.y < self.mapH): + continue + if self.mapPf.getTileEnv(tile) > 1: # wall or unpassable + continue + if ct.get_tile_building_id(tile) is not None: + continue + fallback.append(tile) + if (tile.x, tile.y) not in covered: # never block a team gunner line + candidates.append(tile) + if self.mapPf.myNum == 4: + candidates.sort(key=lambda tile: tile.distance_squared(center)) # 4th bot: closest to center + fallback.sort(key=lambda tile: tile.distance_squared(center)) + else: + candidates.sort(key=lambda tile: tile.distance_squared(center), reverse=True) # 6th bot: farthest + fallback.sort(key=lambda tile: tile.distance_squared(center), reverse=True) + if candidates: + self.defendHome = candidates[0] + elif fallback: + self.defendHome = fallback[0] + return self.defendHome + + def healCore(self, ct: Controller, home=None): + myLoc = ct.get_position() + teamCore = self.mapPf.teamCore + if teamCore is None: + return + coreTiles = [teamCore, teamCore.add(Direction.EAST), + teamCore.add(Direction.SOUTH), + teamCore.add(Direction.SOUTH).add(Direction.EAST)] + for tile in coreTiles: + if myLoc.distance_squared(tile) == 1 and ct.can_heal(tile): + ct.heal(tile) + return + if home is None: + home = self.getDefendHome(ct) + if home is not None and myLoc != home: + self.mapPf.moveTo(ct, home) + + def buildGunnerFor(self, ct: Controller, target: Position) -> bool: + myLoc = ct.get_position() + myTeam = ct.get_team() + for d in CARDINALS: + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + if ct.get_tile_building_id(spot) is None: + gunnerDir = spot.direction_to(target) + if self.rayBlockedByTeam(ct, spot, target, gunnerDir, myTeam): + continue + if ct.can_build_gunner(spot, gunnerDir): + ct.build_gunner(spot, gunnerDir) + return True + for d in CARDINALS: # try destroying a team barrier in the way + spot = myLoc.add(d) + dist = target.distance_squared(spot) + if dist < 10 and dist != 5 and 0 <= spot.x < self.mapW and 0 <= spot.y < self.mapH: + spotId = ct.get_tile_building_id(spot) + if spotId is not None: + if ct.get_team(spotId) == myTeam and ct.get_entity_type(spotId) == EntityType.BARRIER: + if ct.can_destroy(spot): + ct.destroy(spot) + return True + return False + + def coveredTiles(self, ct: Controller) -> set: + covered = set() + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType in (EntityType.GUNNER, EntityType.SENTINEL): + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, bType): + covered.add((t.x, t.y)) + return covered + + def rayBlockedByTeam(self, ct: Controller, gunnerSpot: Position, attackPos: Position, d: Direction, myTeam) -> bool: + dx, dy = d.delta() + x, y = gunnerSpot.x + dx, gunnerSpot.y + dy + for _ in range(max(self.mapW, self.mapH)): + if (x, y) == (attackPos.x, attackPos.y): + return False + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + return False + if self.mapPf.getTileEnv(Position(x, y)) == 2: + return True + bId = ct.get_tile_building_id(Position(x, y)) + if bId is not None and ct.get_team(bId) == myTeam: + return True + x += dx + y += dy + return False + + def runEco(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + covered = self.coveredTiles(ct) + enemyGunners = [] + visibleDamagedCore = False + for b in ct.get_nearby_buildings(): + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CORE: + visibleDamagedCore = ct.get_hp(b) < ct.get_max_hp(b) + elif bTeam != myTeam and bType in (EntityType.GUNNER, EntityType.SENTINEL): + enemyGunners.append(ct.get_position(b)) + uncovered = [g for g in enemyGunners if (g.x, g.y) not in covered] + if uncovered: + uncovered.sort(key=lambda g: g.distance_squared(myLoc)) + target = uncovered[0] + if self.buildGunnerFor(ct, target): + return + self.mapPf.moveTo(ct, target) + return + # Fixed defenders own covered threats. An economy builder joins healing + # only when it can see that the friendly core is actually damaged; + # otherwise the inherited heal helper merely recalled it home after a + # failed heal and could suppress economy forever under harmless guns. + if enemyGunners and visibleDamagedCore: + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.repairFormerlyConnectedTrunk(ct, myLoc, myTeam): + return + if self.resumeCommittedRoute(ct): + return + if self.routeConveyorTask(ct, myLoc, myTeam): + return + if self.routeHarvesterTask(ct, myLoc, myTeam): + return + if self.harvestTask(ct, myLoc, myTeam): + return + if self.mapPf.teamCore is not None: + self.mapPf.moveTo(ct, self.mapPf.teamCore) + + def clearCommittedRoute(self): + self.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + + def observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, _, convSafe = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if convSafe.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convSafe + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convSafe): + """Return one exact destroyed edge from a formerly draining trunk. + + A missing edge is useful only when it still has a live producer on its + upstream side and its output joins a currently proven suffix. This + excludes abandoned branches and the broad long-route behavior that + already lost its economy gate. + """ + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + candidates = [] + for key, (pos, direction) in self.knownConnectedConveyors.items(): + if key in convDirs or not ct.is_in_vision(pos): + continue + if ct.get_tile_building_id(pos) is not None: + continue + output = pos.add(direction) + outputKey = output.x * 32 + output.y + downstreamLive = output in coreTiles or ( + outputKey in convDirs and convSafe.get(outputKey, False)) + if not downstreamLive: + continue + + upstreamCount = 0 + loadedUpstream = 0 + for data in convDirs.values(): + if data[0].add(data[1]) == pos: + upstreamCount += 1 + loadedUpstream += 1 if data[2] else 0 + for d in CARDINALS: + source = pos.add(d) + if not (0 <= source.x < self.mapW and 0 <= source.y < self.mapH): + continue + if not ct.is_in_vision(source): + continue + sourceId = ct.get_tile_building_id(source) + if (sourceId is not None and ct.get_team(sourceId) == myTeam and + ct.get_entity_type(sourceId) == EntityType.HARVESTER): + upstreamCount += 1 + if upstreamCount == 0: + continue + score = (-loadedUpstream, -upstreamCount, + myLoc.distance_squared(pos), coreFootprintManhattan(pos, core), key) + candidates.append((score, pos, direction)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1], candidates[0][2] + + def repairFormerlyConnectedTrunk(self, ct: Controller, myLoc, myTeam) -> bool: + if self.mapPf.teamCore is None: + return False + convDirs, convSafe = self.observeConnectedConveyors(ct, myTeam) + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convSafe) + if candidate is None: + return False + target, direction = candidate + # Never walk, reserve a role, or publish a claim for repair. Rebuild the + # exact productive edge only when this builder can do it immediately; + # otherwise normal routing/economy continues unchanged this turn. + if ct.can_build_conveyor(target, direction): + ct.build_conveyor(target, direction) + self.knownConnectedConveyors[target.x * 32 + target.y] = ( + target, direction) + return True + return False + + def commitRoute(self, ct: Controller, target: Position) -> bool: + if coreFootprintManhattan(target, self.mapPf.teamCore) > ROUTE_COMMIT_MAX_LINKS: + self.mapPf.routeConveyor(ct, target) + return True + self.routeTarget = target + self.routeBestDistance = None + self.routeLastProgressRound = ct.get_current_round() + return self.resumeCommittedRoute(ct) + + def resumeCommittedRoute(self, ct: Controller) -> bool: + """Resume an interrupted conveyor head until it drains or truly stalls.""" + if self.routeTarget is None or self.mapPf.teamCore is None: + return False + core = self.mapPf.teamCore + coreTiles = { + core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + myTeam = ct.get_team() + currentRound = ct.get_current_round() + + # Walk through an already-built visible suffix immediately. This keeps + # the commitment at the first missing link instead of camping on an + # earlier conveyor after combat or healing interrupted the route. + for _ in range(self.mapW + self.mapH): + target = self.routeTarget + if not (0 <= target.x < self.mapW and 0 <= target.y < self.mapH): + self.clearCommittedRoute() + return False + if target in coreTiles: + self.clearCommittedRoute() + return False + if not ct.is_in_vision(target): + break + targetId = ct.get_tile_building_id(target) + if targetId is None: + break + if (ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + self.clearCommittedRoute() + return False + + target = self.routeTarget + myLoc = ct.get_position() + distance = myLoc.distance_squared(target) + if self.routeBestDistance is None or distance < self.routeBestDistance: + self.routeBestDistance = distance + self.routeLastProgressRound = currentRound + elif currentRound - self.routeLastProgressRound >= ROUTE_STALL_ROUNDS: + self.clearCommittedRoute() + return False + + if not ct.is_in_vision(target): + self.mapPf.moveTo(ct, target) + return True + + self.mapPf.routeConveyor(ct, target) + # routeConveyor may move us while trying to obtain a legal build stand. + # Re-check vision before querying the old head after that move. + if not ct.is_in_vision(target): + return True + targetId = ct.get_tile_building_id(target) + if (targetId is not None and ct.get_team(targetId) == myTeam and + ct.get_entity_type(targetId) == EntityType.CONVEYOR): + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + return True + + def routeConveyorTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.CONVEYOR: + continue + bPos = ct.get_position(b) + endTile = bPos.add(ct.get_direction(b)) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + if ct.get_tile_building_id(endTile) is None: + bScore = max(2, 6 * max(0, (1 - endTile.distance_squared(teamCore) / 120)) * max(0, (1 - myLoc.distance_squared(bPos) / 40))) + if bScore > bestScore: + bestScore = bScore + bestEnd = endTile + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def routeHarvesterTask(self, ct: Controller, myLoc, myTeam) -> bool: + mapW, mapH = self.mapW, self.mapH + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + bestScore, bestEnd = 0, None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam or ct.get_entity_type(b) != EntityType.HARVESTER: + continue + bPos = ct.get_position(b) + if myLoc.distance_squared(bPos) >= 16: + continue + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - workingSpots[0].distance_squared(teamCore) / 100)) * max(0, (1 - myLoc.distance_squared(bPos) / 60))) + if bScore > bestScore: + bestScore = bScore + bestEnd = workingSpots[0] + if bestEnd is not None: + return self.commitRoute(ct, bestEnd) + return False + + def harvestTask(self, ct: Controller, myLoc, myTeam) -> bool: + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + enemyThreatened = self.mapPf.enemyTurretThreatenedTiles(ct) + bestScore, bestTile = 0, None + # scan the whole shared map, not just what's currently in this bot's own vision - + # otherwise ore another bot broadcast over the store slots is never actually visited + for x in range(self.mapW): + for y in range(self.mapH): + if self.mapPf.fullMap[x][y] != 1: + continue + tile = Position(x, y) + if ct.is_in_vision(tile) and ct.get_tile_building_id(tile) is not None: + continue + if (x, y) in enemyThreatened: + continue + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist / 160) * (1 - myLoc.distance_squared(tile) / 220)) + if tileScore > bestScore and ct.get_global_resources() > dist / 7: + bestScore = tileScore + bestTile = tile + if bestTile is None: + return False + if ct.can_build_harvester(bestTile): + ct.build_harvester(bestTile) + return True + if bestTile.distance_squared(myLoc) > 1: + self.mapPf.moveTo(ct, bestTile) + elif bestTile.distance_squared(myLoc) < 1: + self.mapPf.moveTo(ct, teamCore) + return True + diff --git a/bots/exp_v21_eco_release_only_damaged/mapPathfinding.py b/bots/exp_v21_eco_release_only_damaged/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v21_eco_release_only_damaged/mapPathfinding.py @@ -0,0 +1,726 @@ +"""Map pathfinding for OogwayRush. + +Map sharing, convoy routing, loop detection, symmetry-based enemy-core +discovery, threat-aware conveyor routing, and gunner spot generation. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +symSlot = 8 # slots 0-7 are taken (0 numSpawned, 1-6 map sharing, 7 team core loc) +symBits = {'flipX': 1, 'flipY': 2, '180': 4} # store *killed* symmetries so 0 = nothing ruled out + +openCost = 1 +barrierCost = 8 +gunnerCost = 24 # gunners cost more, so higher + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +# shared array. 0: num bots spawned 1-6: new tiles from that bot 8: the map symmetry +gunnerLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 3, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + gunnerLines.append([_dx, _dy, 2, _d]) + + +def coreFootprint(core: Position): + return { + core, + core.add(Direction.EAST), + core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST), + } + + +def visibleConveyorMergeSafe(start, conveyorDirections, coreTiles, visibleTiles): + """Accept only an observed route that drains or leaves vision progressing. + + This is deliberately local and memoryless. A visible dead end or cycle is + never a route sink. A chain that leaves vision is provisionally usable only + when every visible edge strictly reduces Manhattan distance to the core. + """ + position = start + visited = set() + for _ in range(len(conveyorDirections) + 1): + if position in visited: + return False + visited.add(position) + facing = conveyorDirections.get(position) + if facing is None: + return False + output = position.add(facing) + currentDistance = min( + abs(position.x - core.x) + abs(position.y - core.y) + for core in coreTiles) + outputDistance = min( + abs(output.x - core.x) + abs(output.y - core.y) + for core in coreTiles) + if outputDistance >= currentDistance: + return False + if output in coreTiles: + return True + if output in conveyorDirections: + position = output + continue + return output not in visibleTiles + return False + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.convDirs = {} + self.convLoop = {} + self.convMergeSafe = {} + self.convRound = -1 + self.allSymmetries = ['flipX', 'flipY', '180'] + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + self.updateSymmetry(ct) + + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or (tType != EntityType.BARRIER and tType != EntityType.GUNNER): + return False + return True + + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.checkTileSymmetry(tile, tileType) # before we mark it seen, so it cant test itself + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.checkTileSymmetry(Position(x, y), t_type) # tiles other bots found count too + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif tTeam == ct.get_team() and tType == EntityType.GUNNER: + return gunnerCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def enemyTurretThreatenedTiles(self, ct: Controller): + curRound = ct.get_current_round() + if self.enemyThreatRound == curRound: + return self.enemyThreatCache + myTeam = ct.get_team() + threatened = set() + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.GUNNER: + bPos = ct.get_position(b) + for d in DIRECTIONS: + for t in ct.get_attackable_tiles_from(bPos, d, EntityType.GUNNER): + threatened.add((t.x, t.y)) + elif bType == EntityType.SENTINEL: + bPos = ct.get_position(b) + bDir = ct.get_direction(b) + for t in ct.get_attackable_tiles_from(bPos, bDir, EntityType.SENTINEL): + threatened.add((t.x, t.y)) + self.enemyThreatCache = threatened + self.enemyThreatRound = curRound + return threatened + + def conveyorTileCostSafe(self, ct: Controller, tile: Position, enemyThreatened, curEnd: Position): + cost, status = self.conveyorTileCost(ct, tile, curEnd) + if cost is not None and (tile.x, tile.y) in enemyThreatened: + return [cost+24, status] + return [cost, status] + + def conveyorTileCost(self, ct: Controller, tile: Position, curEnd: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + endDist = curEnd.distance_squared(self.teamCore) + otherDist = tile.distance_squared(self.teamCore) + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if self.convLoop.get(tile.x * 32 + tile.y): + return [8, 'working'] # in a cycle: build through it, never a sink + if not self.convMergeSafe.get(tile.x * 32 + tile.y, False): + return [2, 'working'] # visible dead end or non-progressing branch + if ct.get_stored_resource(tileId) is not None: + return [24, 'done'] # loaded + if endDist > otherDist: + return [30, 'done'] + return [2, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + if tType == EntityType.GUNNER: + return [gunnerCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def classifyConveyors(self, ct: Controller, myTeam): + curRound = ct.get_current_round() + if self.convRound == curRound: + return self.convDirs, self.convLoop, self.convMergeSafe + w, h = self.mapW, self.mapH + convDirs = {} + for b in ct.get_nearby_buildings(): + if ct.get_team(b) == myTeam and ct.get_entity_type(b) == EntityType.CONVEYOR: + bPos = ct.get_position(b) + convDirs[bPos.x * 32 + bPos.y] = [bPos, ct.get_direction(b), + ct.get_stored_resource(b) is not None] + convLoop = {} + for startKey in convDirs: + if startKey in convLoop: # already resolved as part of an earlier chain + continue + thisPath = [] + onPath = {} + cur = startKey + isLoop = False + while True: + if cur in onPath: # walked back onto this same chain + isLoop = True + break + if cur in convLoop: # joins a chain we already have a verdict for + isLoop = convLoop[cur] + break + onPath[cur] = True + thisPath.append(cur) + curPos, curDir, curLoaded = convDirs[cur] + nextPos = curPos.add(curDir) + if not (0 <= nextPos.x < w and 0 <= nextPos.y < h): + break # points off the map, cant be a cycle + nextKey = nextPos.x * 32 + nextPos.y + if nextKey not in convDirs: + break # core, out of vision, or dead end - assume it drains + cur = nextKey + for key in thisPath: # anything upstream of a cycle never drains either + convLoop[key] = isLoop + self.convDirs = convDirs + self.convLoop = convLoop + directionByPosition = { + data[0]: data[1] for data in convDirs.values() + } + visibleTiles = set(ct.get_nearby_tiles()) + coreTiles = coreFootprint(self.teamCore) + self.convMergeSafe = { + key: visibleConveyorMergeSafe( + data[0], directionByPosition, coreTiles, visibleTiles) + for key, data in convDirs.items() + } + self.convRound = curRound + return convDirs, convLoop, self.convMergeSafe + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + convDirs, convLoop, convMergeSafe = self.classifyConveyors(ct, myTeam) + for key in convDirs: + bPos, bDir, bLoaded = convDirs[key] + if convLoop[key] or not convMergeSafe[key]: + continue # only proven/progressing drains seed the reverse search + cost = 28 if bLoaded else 4 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True # still expand below - routeConveyor needs curEnd's own + # neighbors labeled, especially the outward one away from core, which + # can only ever be reached by expanding from curEnd itself + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCostSafe(ct, nextTile, enemyThreatened, curEnd) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def createsLoop(self, buildPos: Position, d: Direction): + w, h = self.mapW, self.mapH + buildKey = buildPos.x * 32 + buildPos.y + cur = buildPos.add(d) + onPath = {} + while True: + if not (0 <= cur.x < w and 0 <= cur.y < h): + return False # ran off the map, cant be a cycle + curKey = cur.x * 32 + cur.y + if curKey == buildKey: + return True # chain leads back into the tile we are about to build + if curKey in onPath: + return True # downstream loops on itself, so we would feed it + if self.convLoop.get(curKey): + return True # already flagged as looping + if curKey not in self.convDirs: + return False # core, empty ground, or out of vision - assume it drains + onPath[curKey] = True + curPos, curDir, curLoaded = self.convDirs[curKey] + cur = curPos.add(curDir) + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist and not self.createsLoop(curEnd, d): + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + if myLoc == curEnd: + # standing on the chain head can't build (needs adjacency) - step off to any + # movable cardinal neighbor so we're adjacent to curEnd again next turn + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + return + elif myLoc.distance_squared(curEnd) == 1: + curId = ct.get_tile_building_id(curEnd) + if curId is not None and ct.get_team(curId) == ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + elif curId is not None and ct.get_team(curId) != ct.get_team() and ct.get_entity_type(curId) == EntityType.CONVEYOR: + if ct.can_fire(curEnd): + ct.fire(curEnd) + return + elif myLoc.distance_squared(curEnd) == 2: + for d in CARDINALS: + newLoc = myLoc.add(d) + if newLoc.distance_squared(curEnd) == 1 and ct.can_move(d): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + + nearbyLaunchers = [] + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != ct.get_team() and ct.get_entity_type(b) == EntityType.LAUNCHER: + nearbyLaunchers.append(ct.get_position(b)) + + bestDist = 4096 + bestDir = None + + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER or tType == EntityType.GUNNER): + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and (tType == EntityType.BARRIER): + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # this is intentional + + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if isLauncherThreat: + attackingSpots = self.gunnerSpots(nearbyLaunchers[0], self.mapW, self.mapH, True) + for spot in attackingSpots: # check if a gunner has already been placed + if not ct.is_in_vision(spot[0]): + continue + spotId = ct.get_tile_building_id(spot[0]) + if spotId is not None: + spotType = ct.get_entity_type(spotId) + spotTeam = ct.get_team(spotId) + if spotType == EntityType.GUNNER and spotTeam == ct.get_team(): + spotDir = ct.get_direction(spotId) + if spotDir == spot[1]: + # a gunner has already been placed + return + if ct.get_global_resources() > 80: + if ct.can_build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])): + ct.build_gunner(nextPos, nextPos.direction_to(nearbyLaunchers[0])) + else: + self.stuckTurns += 1 + return + + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + nextPos = myLoc.add(i) + if ct.can_move(i): + nearbyLaunchers.sort(key=lambda launcherPos: launcherPos.distance_squared(nextPos)) + isLauncherThreat = len(nearbyLaunchers) > 0 and nearbyLaunchers[0].distance_squared(nextPos) < 4 + if not isLauncherThreat: + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] + + def gunnerSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a gunner could stand and hit target. + Walks each line outward from target, so the facing returned is the + reverse of the walk. With blocked=True a wall or building on the line + ends that direction early - it cannot be stood on and it shields + everything behind it. Unseen tiles are assumed clear. mapW/mapH are + taken explicitly so this also works without setupMap() (e.g. from + the core, or a spot planner that has not built the full map).""" + spots = [] + for dx, dy, maxK, d in gunnerLines: + x, y = target.x, target.y + for k in range(1, maxK + 1): + x -= dx + y -= dy + if not (0 <= x < mapW and 0 <= y < mapH): + break + if blocked and self.fullMap is not None and self.fullMap[x][y] > 1: + break + spots.append([Position(x, y), d]) + return spots + + def mirrorTile(self, tile: Position, sym): # where tile lands under a symmetry + if sym == 'flipX': + return Position(self.mapW - 1 - tile.x, tile.y) + if sym == 'flipY': + return Position(tile.x, self.mapH - 1 - tile.y) + return Position(self.mapW - 1 - tile.x, self.mapH - 1 - tile.y) # 180 + + def mirrorCore(self, sym): # -2 not -1: the core is 2x2 and we track its top left corner + x, y, w, h = self.teamCore.x, self.teamCore.y, self.mapW, self.mapH + if sym == 'flipX': + return Position(w - x - 2, y) + if sym == 'flipY': + return Position(x, h - y - 2) + return Position(w - x - 2, h - y - 2) # 180 + + def killSymmetry(self, sym): + if sym in self.allSymmetries: + self.allSymmetries.remove(sym) + if sym in self.allEnemyCore: + del self.allEnemyCore[sym] + + def checkTileSymmetry(self, tile: Position, tileType): # called per newly known tile - cheap + if self.mapSymmetry is not None or len(self.allSymmetries) <= 1: + return + isWall = (tileType == 2) + for sym in list(self.allSymmetries): # list() since killSymmetry mutates it + m = self.mirrorTile(tile, sym) + if not self.seenTiles[m.x][m.y]: + continue # mirror unknown, this tile tells us nothing yet + if (self.fullMap[m.x][m.y] == 2) != isWall: # wall vs not-wall disagrees + self.killSymmetry(sym) + + def updateSymmetry(self, ct: Controller): # called once per turn + if self.teamCore is None or self.enemyCorePos is not None: + if self.enemyCorePos is not None: # teamCore None gets here too, with nothing to draw + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + if len(self.allEnemyCore) == 0 and len(self.allSymmetries) > 0: + for sym in self.allSymmetries: + self.allEnemyCore[sym] = self.mirrorCore(sym) + + # fold in what every other bot has already ruled out (slot 8 holds killed bits) + killedMask = ct.read_store(symSlot) + for sym in list(self.allSymmetries): + if killedMask & symBits[sym]: + self.killSymmetry(sym) + + # eliminate or confirm candidates we can actually see + for sym in list(self.allSymmetries): + possibleCore = self.allEnemyCore.get(sym) + if possibleCore is None or not ct.is_in_vision(possibleCore): + continue + tId = ct.get_tile_building_id(possibleCore) + if tId is not None and ct.get_entity_type(tId) == EntityType.CORE and ct.get_team(tId) != ct.get_team(): + self.enemyCorePos = possibleCore + self.mapSymmetry = sym + self.allSymmetries = [sym] + self.allEnemyCore = {sym: possibleCore} # drop the disproved candidates + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + self.killSymmetry(sym) # looked, no enemy core there, so this symmetry is wrong + + # rebroadcast our whole verdict every turn: if two bots kill different bits on the + # same turn one write is lost, and re-oring the full mask heals that next turn + myMask = 0 + for sym in symBits: + if sym not in self.allSymmetries: + myMask |= symBits[sym] + if myMask != killedMask: + ct.write_store(symSlot, killedMask | myMask) + + # every remaining symmetry might still be ambiguous, but if they all agree + # on the same spot, that IS the enemy core - commit to it even though we + # cant pick a single symmetry yet + if len(self.allSymmetries) > 1: + candidates = {self.allEnemyCore[sym] for sym in self.allSymmetries if sym in self.allEnemyCore} + if len(candidates) == 1: + self.enemyCorePos = next(iter(candidates)) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 1: + self.mapSymmetry = self.allSymmetries[0] + self.enemyCorePos = self.allEnemyCore.get(self.mapSymmetry) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return + + if len(self.allSymmetries) == 0: + # every symmetry hypothesis disproven and still no enemy core - fall back to + # scanning whatever buildings we can actually see for a visible enemy CORE + for b in ct.get_nearby_buildings(): + if ct.get_entity_type(b) == EntityType.CORE and ct.get_team(b) != ct.get_team(): + self.enemyCorePos = ct.get_position(b) + ct.draw_indicator_line(self.enemyCorePos, ct.get_position(), 0, 255, 255) + return diff --git a/bots/exp_waller_route_light/README.md b/bots/exp_waller_route_light/README.md new file mode 100644 index 0000000..8676129 --- /dev/null +++ b/bots/exp_waller_route_light/README.md @@ -0,0 +1,27 @@ +# Lightweight Waller Route Repair experiment + +**Mechanism pass, promotion hold.** Three complete parent gates on engine +2.3.3 combine to 256/504 (50.8%), core kills 210-211. The 81 round-1000 games +went 45-36 (55.6%) with mean collected titanium 3,328 versus 3,245. The change +improves the reported failure and is aggregate-neutral, but misses the declared +nonnegative core-kill gate by one. Do not stack, package, upload, or promote +without new production replay evidence. + +Parent: frozen `meta-generalist-v1`. + +The facing-aware repair mechanism improved long-game titanium in two full +gates, but early versions scanned economy buildings inside every builder's hot +map loop. The waller-only version then lost 2/12 to the rush, including several +pre-activation games, identifying observation overhead as an independent bug. + +This variant preserves the waller-only, heal-gated round-120 repair while +moving graph observation completely out of `MapPathfinder.getNewTiles`. +Ordinary builders never scan the economic graph. The waller starts refreshing +visible harvester positions and conveyor facings only at the activation round. +Before round 120, the parent hot path and actions are unchanged except for one +short-circuited round-number comparison. + +Repair remains bounded to eight links and 100 titanium, merges only into an +empty facing-proven trunk, and may rebuild one wrong-facing or cyclic link +rooted at an orphan harvester. No map, dimensions, side, opponent, or new store +state is used. Root, frozen parent, and live package are untouched. diff --git a/bots/exp_waller_route_light/initialSpawning.py b/bots/exp_waller_route_light/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_waller_route_light/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_waller_route_light/main.py b/bots/exp_waller_route_light/main.py new file mode 100644 index 0000000..68854c1 --- /dev/null +++ b/bots/exp_waller_route_light/main.py @@ -0,0 +1,1345 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 120 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +LATE_ROUTE_FALLBACK_SCORE_CAP = 1.5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer( + candidates, target: Position, excluded_ids=(), preferred_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: ( + 0 if item[0] in preferred_ids else 1, + item[1].distance_squared(target), + item[0], + ), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if (ct.get_current_round() >= LATE_ROUTE_REPAIR_START + and self._is_waller(ct)): + self.mapPf.refreshEconomyGraph(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + wallerEncoded = ct.read_store(SLOT_WALLER_ID) + preferred = {wallerEncoded - 1} if wallerEncoded else set() + return choose_route_repairer( + candidates, target, excluded, preferred) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's exact transition. Only after round 80 may one + # nearest non-specialist finish a short route to an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._is_waller(ct) + or healScore > 0 + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_waller_route_light/mapPathfinding.py b/bots/exp_waller_route_light/mapPathfinding.py new file mode 100644 index 0000000..038f3f3 --- /dev/null +++ b/bots/exp_waller_route_light/mapPathfinding.py @@ -0,0 +1,647 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def refreshEconomyGraph(self, ct: Controller): + """Refresh graph state only when the designated repairer needs it.""" + for tile in ct.get_nearby_tiles(): + self.observeEconomyTile(ct, tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_waller_route_light/symmetry.py b/bots/exp_waller_route_light/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_waller_route_light/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_waller_route_repair/README.md b/bots/exp_waller_route_repair/README.md new file mode 100644 index 0000000..ba160a9 --- /dev/null +++ b/bots/exp_waller_route_repair/README.md @@ -0,0 +1,29 @@ +# Waller Route Repair experiment + +**Crash-screen reject.** 4/12 versus parent and 2/12 versus rush, with several +losses before the round-120 feature boundary. Cause: economy-graph observation +was still hooked into every builder's every-tile map scan from round 1. The +successor must remove that hot-path overhead and refresh only on the waller +after activation. + +Parent: frozen `meta-generalist-v1`. + +Prior variants established the tradeoff on production engine 2.3.3: + +- Round-80 repair won 19/25 long games with a large titanium advantage, but + lost core kills 64-79 over 168. +- Waller-preferred repair still let normal builders substitute and lost core + kills 64-77. +- Round-300 repair restored core kills to 70-71 but was too late to improve the + long-game economy. + +This independent allocation variant permits repair from round 120, but only the +parent's existing wall-role builder may act. That role is already prohibited +from normal attacks. It also defers whenever any friendly entity needs healing, +so home support remains ahead of economy. There is no normal-builder fallback. + +The actual repair is unchanged: facing-proven core connectivity, one local +claim, at most eight missing links, at least 100 titanium, no loaded or unproven +merge, and surgical rebuild of a wrong-facing/cyclic link rooted at an orphan +harvester. No map, dimensions, side, opponent, or new store state is used. +Root, frozen parent, and live package remain untouched. diff --git a/bots/exp_waller_route_repair/initialSpawning.py b/bots/exp_waller_route_repair/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_waller_route_repair/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_waller_route_repair/main.py b/bots/exp_waller_route_repair/main.py new file mode 100644 index 0000000..7b5bb6c --- /dev/null +++ b/bots/exp_waller_route_repair/main.py @@ -0,0 +1,1342 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 120 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 100 +LATE_ROUTE_SCORE_CAP = 6 +LATE_ROUTE_FALLBACK_SCORE_CAP = 1.5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer( + candidates, target: Position, excluded_ids=(), preferred_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: ( + 0 if item[0] in preferred_ids else 1, + item[1].distance_squared(target), + item[0], + ), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + wallerEncoded = ct.read_store(SLOT_WALLER_ID) + preferred = {wallerEncoded - 1} if wallerEncoded else set() + return choose_route_repairer( + candidates, target, excluded, preferred) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's exact transition. Only after round 80 may one + # nearest non-specialist finish a short route to an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._is_waller(ct) + or healScore > 0 + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_waller_route_repair/mapPathfinding.py b/bots/exp_waller_route_repair/mapPathfinding.py new file mode 100644 index 0000000..0c192e6 --- /dev/null +++ b/bots/exp_waller_route_repair/mapPathfinding.py @@ -0,0 +1,643 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + self.observeEconomyTile(ct, tile) + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_waller_route_repair/symmetry.py b/bots/exp_waller_route_repair/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_waller_route_repair/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/bots/exp_waller_route_reserve/README.md b/bots/exp_waller_route_reserve/README.md new file mode 100644 index 0000000..3275b66 --- /dev/null +++ b/bots/exp_waller_route_reserve/README.md @@ -0,0 +1,15 @@ +# Reserve-gated Waller Route Repair + +Parent: `exp_waller_route_light`, an exact-v9 branch. + +The 50-game production audit confirmed that incomplete conveyor networks are a +general secondary weakness, but 25 of 27 losses were still core destructions. +This variant therefore changes only the late-repair reserve: the designated +non-attacking waller may repair an observed orphan route from round 120 only +when at least 200 titanium remains, rather than 100. Healing and home response +still override it, the repair stays bounded to eight links, and the opening, +siege, spawning, and combat logic are unchanged. + +No map, dimension, side, opponent, or submission identity is inspected. This +is a local experiment only; it is not packaged, uploaded, activated, or copied +to root. diff --git a/bots/exp_waller_route_reserve/initialSpawning.py b/bots/exp_waller_route_reserve/initialSpawning.py new file mode 100644 index 0000000..d242794 --- /dev/null +++ b/bots/exp_waller_route_reserve/initialSpawning.py @@ -0,0 +1,120 @@ + +import itertools +from fcode import Controller, Direction, EntityType, Environment, Position + +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] + + +class initialSpawn: + def getCardPath(self, ct: Controller, start: Position, target: Position): + dX = target.x - start.x + dY = target.y - start.y + ewDir = Direction.EAST if dX > 0 else Direction.WEST + nsDir = Direction.NORTH if dY < 0 else Direction.SOUTH + dX, dY = abs(dX), abs(dY) + + if dX == 0: + return [nsDir] * dY + if dY == 0: + return [ewDir] * dX + + if dY > dX: + primaryDir, secondaryDir = nsDir, ewDir + primaryCount, secondaryCount = dY, dX + else: + primaryDir, secondaryDir = ewDir, nsDir + primaryCount, secondaryCount = dX, dY + + numInPattern = primaryCount // secondaryCount + patternBlock = [primaryDir] * numInPattern + [secondaryDir] + + path = patternBlock * secondaryCount + path += [primaryDir] * (primaryCount - numInPattern * secondaryCount) + return path + + def pickBestRays(self, ct: Controller, rayScores, coreCorners): + if len(rayScores) < 5: + angles = [] + for ray in rayScores: + angles.append(ray[1]) + for i in rayScores: + index = anglePerDir.index(i[1]) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return angles + bestScore = 0 + bestAngles = [] + for thisFive in itertools.combinations(rayScores, 5): + curScore = 0 + angles = [] + for ray in thisFive: + curScore += ray[0] + angles.append(ray[1]) + angles.sort() + product = 1 + for i in range(len(angles)-1): + product = product * (angles[i+1]-angles[i])/30 + product = product * (360 - angles[-1] + angles[0])/30 + curScore += product + if curScore > bestScore: + bestScore = curScore + bestAngles = angles + for i in bestAngles: + index = anglePerDir.index(i) + start = Position(coreCorners[index // 4].x + spawnPoints[index].x, coreCorners[index // 4].y + spawnPoints[index].y) + target = Position(start.x + directionMoves[index].x, start.y + directionMoves[index].y) + ct.draw_indicator_line(start, target, 50, 70, 180) + return bestAngles + def setBestFive (self, ct: Controller): + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + mapW = ct.get_map_width() + mapH = ct.get_map_height() + rayScores = [] + for i in range(16): + rayPath = self.getCardPath(ct, spawnPoints[i], directionMoves[i]) + start = Position(coreCorners[i // 4].x + spawnPoints[i].x, coreCorners[i // 4].y + spawnPoints[i].y) + rayScore = 0 + for j in rayPath: + start = start.add(j) + if 0 <= start.x < mapW and 0 <= start.y < mapH: + ct.draw_indicator_dot(start, 255, 255, 255) + if ct.is_in_vision(start): + env = ct.get_tile_env(start) + if env == Environment.ORE_TITANIUM: + rayScore += 16 + elif env == Environment.EMPTY: + rayScore += 1 + else: + break + else: + break + else: + break + if rayScore > 3: # super short paths are not worth exploring + rayScores.append([rayScore, anglePerDir[i]]) + return self.pickBestRays(ct, rayScores, coreCorners) \ No newline at end of file diff --git a/bots/exp_waller_route_reserve/main.py b/bots/exp_waller_route_reserve/main.py new file mode 100644 index 0000000..5e3b798 --- /dev/null +++ b/bots/exp_waller_route_reserve/main.py @@ -0,0 +1,1345 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position +from symmetry import SymmetryTracker + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +from mapPathfinding import * +from initialSpawning import * +anglePerDir = [ + 135, 108, 162, 90, + 45, 72, 18, 0, + 315, 288, 342, 270, + 225, 252, 198, 180 +] +spawnPoints = [ + Position(-1, -1), Position(0, -1), Position(-1, 0), Position(0, -1), # tL + Position(1, -1), Position(0, -1), Position(1, 0), Position(1, 0), # tR + Position(1, 1), Position(0, 1), Position(1, 0), Position(0, 1), # bR + Position(-1, 1), Position(0, 1), Position(-1, 0), Position(-1, 0) # bL +] +directionMoves = [ + Position(-6, -6), Position(-2, -6), Position(-6, -2), Position(0, -6), # tL + Position(6, -6), Position(2, -6), Position(6, -2), Position(6, 0), # tR + Position(6, 6), Position(2, 6), Position(6, 2), Position(0, 6), # bR + Position(-6, 6), Position(-2, 6), Position(-6, 2), Position(-6, 0) # bL +] +gunnerAttacks = [ + Position(0, 1), Position(0, 2), Position(0, 3), + Position(0, -1), Position(0, -2), Position(0, -3), + Position(-1, 0), Position(-2, 0), Position(-3, 0), + Position(1, 0), Position(2, 0), Position(3, 0), + Position(-1, 1), Position(-2, 2), + Position(-1, -1), Position(-2, -2), + Position(1, 1), Position(2, 2), + Position(1, -1), Position(2, -2) +] +# slot 0 numSpawned, slot 1-6 map sharing, slot 7 initial target +SLOT_WALLER_ID = 8 +SLOT_SIEGER_1_ID = 9 +SLOT_SIEGER_2_ID = 10 +SLOT_ENEMY_CORE = 11 +SLOT_RECALL = 12 +SLOT_SIEGER_2_COUNTER_TARGET = 13 +SLOT_HOME_DEFENDER_ID = 14 +SLOT_HOME_THREAT = 15 +SIEGE_START = 45 +SIEGE_TITANIUM_FLOOR = 120 +# PORTED FROM THE TEAM'S v6 (_OogwayBest), which is the only bot any of us has +# fielded that beats Pantheon: 10-5 (67%) live, where our v2 went 1-14 (7%) and +# v3 went 1-19 (5%). Pantheon wins 86% of its games by core destruction at a +# median of t50, rushing ~18 guns at the core; a single designated home defender +# plus a bounded countertrade - all this lineage has - does not answer that. +# v6's answer is a TEAM-WIDE RECALL: the core raises a flag when it is genuinely +# hurt and a real shooter is in vision, and everybody comes home. It also +# matches my own measurement that stripping home guns out (exp_core_pressure) +# cost 9 points against a rusher. +# Both conditions matter. HP alone would recall on chip damage; a threat alone +# would recall on every scout that wanders past. +RECALL_CORE_HP = 400 +# Traced builder-by-builder from the 0-5 unrated sweep by Orizon (#3): +# ORIZON b10: t6 harv, t8 conv, t12 harv, t14 conv, t16 conv -> STOPS FOREVER +# ORIZON b4: t30,31,37,43 gunner b6: t33,34,35 gunner +# US b3: t3 harv ... t25 conv, t42 harv, t44 conv, t46 conv, t49 conv +# US b9: t7 harv, t9 conv, t11 conv, t25 GUNNER, t50 harv, t52 conv +# Their economy stops at t16 and everything after is guns: 14 built, 10.4 landed +# on our core. Ours never stops - 18.4 conveyors a game, 5 guns, 1.0 on target - +# and builder 9 literally builds a gun and then goes back to harvesting. +# This is a TRANSITION, not an allocation, which is why exp_phase (role cadre) +# and spar_pantheon (economy damped from t0) both missed it: the economy has to +# be built FULLY and then abandoned. +ECON_CUTOFF = 40 +LATE_ROUTE_REPAIR_START = 120 +LATE_ROUTE_MAX_LINKS = 8 +LATE_ROUTE_RESOURCE_FLOOR = 200 +LATE_ROUTE_SCORE_CAP = 6 +LATE_ROUTE_FALLBACK_SCORE_CAP = 1.5 +HOME_DEFENDER_HOLD_ROUNDS = 5 + + +def pack_position(pos: Position) -> int: + return ((pos.x + 1) << 6) | (pos.y + 1) + + +def unpack_position(value: int): + if value == 0: + return None + return Position((value >> 6) - 1, (value & 0x3F) - 1) + + +def direct_plans_all_covered(plans, unsafe_tiles) -> bool: + return bool(plans) and all( + seat in unsafe_tiles or stand in unsafe_tiles + for _, seat, stand, _ in plans + ) + + +def home_counter_budget_allows(resources: int, bounded_trade: bool) -> bool: + return bounded_trade or resources > SIEGE_TITANIUM_FLOOR + + +def late_route_repair_allowed( + round_number: int, resources: int, remaining_links: int) -> bool: + return ( + round_number >= LATE_ROUTE_REPAIR_START + and resources >= LATE_ROUTE_RESOURCE_FLOOR + and remaining_links <= LATE_ROUTE_MAX_LINKS + ) + + +def choose_route_repairer( + candidates, target: Position, excluded_ids=(), preferred_ids=()): + eligible = [item for item in candidates if item[0] not in excluded_ids] + if not eligible or target is None: + return None + return min( + eligible, + key=lambda item: ( + 0 if item[0] in preferred_ids else 1, + item[1].distance_squared(target), + item[0], + ), + )[0] + + +def core_footprint(core: Position): + return [ + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + + +def adjacent_to_core_footprint(position: Position, core: Position) -> bool: + if position in core_footprint(core): + return False + return ( + core.x - 1 <= position.x <= core.x + 2 + and core.y - 1 <= position.y <= core.y + 2 + ) + + +def choose_home_defender(candidates, sieger_ids, threat: Position): + """Pick one nearby builder: non-sieger, distance, then entity ID.""" + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + 1 if item[0] in sieger_ids else 0, + item[1].distance_squared(threat), + item[0], + ), + )[0] + + +class Player: + def __init__(self): + self.mapPf = MapPathfinder() + self.initSpawn = initialSpawn() + self.numSpawned = 0 + self.fiveDirections = None + self.initTarget = None + self.turnsAlive = 0 + self.attackBan = 0 + self.mapW = None + self.mapH = None + self._wallSet = None # deny tiles around our core, computed once + self.symmetry = None + self.symmetryVersion = 0 + self.enemyCorePos = None + self.siegeCommitted = False + self._homeDefenderId = None + self._homeThreatPos = None + self._homeThreatLastSeen = None + + def runCore(self, ct: Controller) -> None: + if self.numSpawned == 0: + self.fiveDirections = self.initSpawn.setBestFive(ct) + ct.draw_indicator_dot(Position(0, 0), 204, 23, 123) + if self.fiveDirections and len(self.fiveDirections) > 0: # only the first 5 bots spawned should be there + spawnAngle = self.fiveDirections[0] + index = anglePerDir.index(spawnAngle) + myLoc = ct.get_position() + tL = myLoc + tR = myLoc.add(Direction.EAST) + bL = myLoc.add(Direction.SOUTH) + bR = myLoc.add(Direction.SOUTH).add(Direction.EAST) + coreCorners = [tL, tR, bR, bL] + + spawnPos = Position( + coreCorners[index // 4].x + spawnPoints[index].x, + coreCorners[index // 4].y + spawnPoints[index].y + ) + target = Position(spawnPos.x + directionMoves[index].x, spawnPos.y + directionMoves[index].y) + target = Position( + max(0, min(target.x, self.mapW - 1)), + max(0, min(target.y, self.mapH - 1)) + ) + if ct.can_spawn(spawnPos): + spawned_id = ct.spawn_builder(spawnPos) + if self.numSpawned == 0: + ct.write_store(SLOT_WALLER_ID, spawned_id + 1) + elif self.numSpawned == 1: + ct.write_store(SLOT_SIEGER_1_ID, spawned_id + 1) + elif self.numSpawned == 2: + ct.write_store(SLOT_SIEGER_2_ID, spawned_id + 1) + self.numSpawned += 1 + ct.write_store(0, self.numSpawned ) + ct.write_store(7, target.x * 32 + target.y) # + self.fiveDirections.remove(spawnAngle) # so it doesnt spawn in the same spot twice. + globalAmmo = ct.get_global_ammo() + globalTitanium = ct.get_global_resources() + + if globalTitanium > 80 + 60 * self.numSpawned: + for i in ct.get_nearby_tiles(): + if ct.can_spawn(i): + ct.spawn_builder(i) + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + corners.sort(key=lambda corner: corner.distance_squared(ct.get_position())) + ct.write_store(7, corners[0].x * 32 + corners[0].y) + break + if globalAmmo < 20 and globalTitanium > 100: + if ct.can_convert_ammo(20 - globalAmmo): + ct.convert_ammo(20 - globalAmmo) + # RECALL: hurt core AND a real shooter in vision, not either alone. + under_attack = (ct.get_hp() < RECALL_CORE_HP + and bool(self._home_threats(ct))) + ct.write_store(SLOT_RECALL, 1 if under_attack else 0) + self._assign_home_defender(ct) + + def run(self, ct: Controller) -> None: + # dev26: an uncaught exception PERMANENTLY destroys this unit (a + # CPU timeout only skips the turn) — one bad tile query must never + # cost a unit + try: + if self.mapW is None: + self.mapH = ct.get_map_height() + self.mapW = ct.get_map_width() + self.symmetry = SymmetryTracker(self.mapW, self.mapH) + etype = ct.get_entity_type() + if etype == EntityType.CORE: + self.runCore(ct) + elif etype == EntityType.BUILDER_BOT: + self.builderBot(ct) + elif etype == EntityType.GUNNER: + self.runGunner(ct) + except Exception: + pass + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.numSpawned = ct.read_store(0) + self.mapPf.setupMap(ct) + if (ct.get_current_round() >= LATE_ROUTE_REPAIR_START + and self._is_waller(ct)): + self.mapPf.refreshEconomyGraph(ct) + self._update_enemy_core(ct) + if self.initTarget is None: # set initial target for the first explore + compact = ct.read_store(7) + self.initTarget = Position(compact // 32, compact % 32) + self.turnsAlive += 1 + if self._is_home_defender(ct): + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return + # WALL DUTY: the core publishes the first builder's exact entity ID. + # during rounds 6-45, then rejoins normal life. Skerry t55 autopsy: + # the passive wall needs foot traffic near the core, and on econ- + # spread maps every builder leaves instantly — zero wall, doorstep + # gunners at t29. One dedicated builder for ~40 rounds is bounded + # (the all-crew wall STATE starved the econ, 32% gate). Duty is + # Store counters and global entity-id thresholds are not stable role + # assignments; the core writes the ID returned by spawn_builder(). + if (self._is_waller(ct) + and 5 < ct.get_current_round() <= 60 + and self.mapPf.teamCore is not None): + # FULL WALL DUTY — the measured design space: full duty = 85% + # vs the wave meta / 44% mirror; watchman = too late always; + # micro-duty = wave sidesteps (t62 kills). This bot is the + # ANTI-RUSH SPECIALIST; its sibling oogerebus is the + # mirror-strong generalist. Field per expected opponent. + if self.wallDuty(ct, myLoc): + return + # VARIANT B: dispatch on state, not on a round number. exp_early_siege + # moved SIEGE_START 45 -> 25 and bought 26 turns (first gun t84 -> t58), + # but the ladder's top two land theirs at t19-t24 and the rest of our + # gap is TRAVEL: the siegers still start at home and walk 25-30 tiles. + # The only honest trigger is "we know where their core is and we can + # afford a gun" - both true from roughly t5 via the symmetry tracker. + # The titanium floor is unchanged, so this is earlier, not cheaper. + if (self._is_sieger(ct) + and (self.siegeCommitted + or (self._enemy_core_target() is not None + and ct.get_global_resources() + > SIEGE_TITANIUM_FLOOR))): + self.siegeCommitted = True + self._run_core_siege(ct, myLoc) + return + self.runBestState(ct, myLoc) + # PASSIVE WALL (the kfort lesson, integrated the right way this + # time): walling is a SIDE EFFECT of a spare action next to a deny + # tile, never a destination. oogwip3/4 made it a state and starved + # the economy (32%/46% gates) — this version costs zero movement + # and zero state priority by construction. + self.passiveWall(ct, ct.get_position()) + + def _update_enemy_core(self, ct: Controller) -> None: + if self.mapPf.mapChanged: + self.symmetryVersion += 1 + if self.mapPf.teamCore is not None: + self.symmetry.update( + self.mapPf.fullMap, self.symmetryVersion, self.mapPf.teamCore) + + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != ct.get_team() + and ct.get_entity_type(building_id) == EntityType.CORE): + self.enemyCorePos = ct.get_position(building_id) + ct.write_store(SLOT_ENEMY_CORE, pack_position(self.enemyCorePos)) + return + + shared = unpack_position(ct.read_store(SLOT_ENEMY_CORE)) + if shared is not None: + self.enemyCorePos = shared + + def _enemy_core_target(self): + if self.enemyCorePos is not None: + return self.enemyCorePos + if self.mapPf.teamCore is None: + return None + x, y = self.symmetry.enemy_core(self.mapPf.teamCore) + return Position(x, y) + + def _is_waller(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_WALLER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _is_sieger(self, ct: Controller) -> bool: + encoded_id = ct.get_id() + 1 + return encoded_id in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + + def _sieger_role(self, ct: Controller) -> int: + encoded_id = ct.get_id() + 1 + if encoded_id == ct.read_store(SLOT_SIEGER_1_ID): + return 1 + if encoded_id == ct.read_store(SLOT_SIEGER_2_ID): + return 2 + return 0 + + def _is_home_defender(self, ct: Controller) -> bool: + assigned = ct.read_store(SLOT_HOME_DEFENDER_ID) + return assigned != 0 and assigned == ct.get_id() + 1 + + def _home_threats(self, ct: Controller): + core = ct.get_position() + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + facing = ct.get_direction(building_id) + attack_tiles = ct.get_attackable_tiles_from( + position, facing, building_type) + if core_tiles.intersection(attack_tiles): + threats.append((0, position.x, position.y, building_id, position)) + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + or ct.get_entity_type(unit_id) != EntityType.BUILDER_BOT): + continue + position = ct.get_position(unit_id) + if adjacent_to_core_footprint(position, core): + threats.append((1, position.x, position.y, unit_id, position)) + threats.sort() + return threats + + def _assign_home_defender(self, ct: Controller) -> None: + threats = self._home_threats(ct) + current_round = ct.get_current_round() + if threats: + self._homeThreatPos = threats[0][4] + self._homeThreatLastSeen = current_round + elif (self._homeThreatLastSeen is None + or current_round - self._homeThreatLastSeen + > HOME_DEFENDER_HOLD_ROUNDS): + self._homeDefenderId = None + self._homeThreatPos = None + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + + my_team = ct.get_team() + candidates = [] + for unit_id in ct.get_nearby_units(): + if (ct.get_team(unit_id) == my_team + and ct.get_entity_type(unit_id) + == EntityType.BUILDER_BOT): + candidates.append((unit_id, ct.get_position(unit_id))) + candidate_ids = {item[0] for item in candidates} + if self._homeDefenderId not in candidate_ids: + sieger_ids = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ) + if encoded + } + self._homeDefenderId = choose_home_defender( + candidates, sieger_ids, self._homeThreatPos) + if self._homeDefenderId is None: + ct.write_store(SLOT_HOME_DEFENDER_ID, 0) + ct.write_store(SLOT_HOME_THREAT, 0) + return + ct.write_store(SLOT_HOME_DEFENDER_ID, self._homeDefenderId + 1) + ct.write_store(SLOT_HOME_THREAT, pack_position(self._homeThreatPos)) + + def _visible_enemy_attack_tiles(self, ct: Controller): + unsafe = set() + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + unsafe.update(ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + building_type, + )) + return unsafe + + def _safe_home_counter_plan( + self, ct: Controller, myLoc: Position, threat: Position): + unsafe = self._visible_enemy_attack_tiles(ct) + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _visible_home_turret_attacks(self, ct: Controller): + core = self.mapPf.teamCore + if core is None: + return [] + core_tiles = set(core_footprint(core)) + my_team = ct.get_team() + threats = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + attack_tiles = set(ct.get_attackable_tiles_from( + position, ct.get_direction(building_id), building_type)) + if core_tiles.intersection(attack_tiles): + threats.append(( + position.x, position.y, building_id, attack_tiles)) + threats.sort(key=lambda item: item[:3]) + return threats + + def _bounded_home_counter_plan( + self, ct: Controller, myLoc: Position, + threat: Position, turret_attacks): + if len(turret_attacks) < 2: + return None + unsafe = set().union( + *(attack_tiles for _, _, _, attack_tiles in turret_attacks)) + best = None + for seat in ct.get_nearby_tiles(): + if (ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + seat_exposure = sum( + seat in attack_tiles + for _, _, _, attack_tiles in turret_attacks) + if seat_exposure > 1: + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, threat): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + seat_exposure, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(threat), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def _friendly_gunner_covers( + self, ct: Controller, threat: Position) -> bool: + my_team = ct.get_team() + for building_id in ct.get_nearby_buildings(): + if (ct.get_team(building_id) != my_team + or ct.get_entity_type(building_id) + != EntityType.GUNNER): + continue + if threat in ct.get_attackable_tiles_from( + ct.get_position(building_id), + ct.get_direction(building_id), + EntityType.GUNNER): + return True + return False + + def _heal_or_approach_core( + self, ct: Controller, myLoc: Position) -> None: + core = self.mapPf.teamCore + if core is None: + return + for core_tile in core_footprint(core): + if ct.can_heal(core_tile): + ct.heal(core_tile) + return + ring = [] + for x in range(core.x - 1, core.x + 3): + for y in range(core.y - 1, core.y + 3): + position = Position(x, y) + if not adjacent_to_core_footprint(position, core): + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if not ct.is_in_vision(position): + continue + if position != myLoc: + if ct.get_tile_builder_bot_id(position) is not None: + continue + if not ct.is_tile_passable(position): + continue + ring.append(position) + if ring: + ring.sort(key=lambda p: ( + myLoc.distance_squared(p), p.x, p.y)) + self.mapPf.moveTo(ct, ring[0]) + + def _run_home_defense( + self, ct: Controller, myLoc: Position, threat: Position) -> None: + if ct.is_in_vision(threat): + threat_ids = [ + ct.get_tile_building_id(threat), + ct.get_tile_builder_bot_id(threat), + ] + if not any( + entity_id is not None + and ct.get_team(entity_id) != ct.get_team() + for entity_id in threat_ids): + self._heal_or_approach_core(ct, myLoc) + return + if self._friendly_gunner_covers(ct, threat): + self._heal_or_approach_core(ct, myLoc) + return + plan = self._safe_home_counter_plan(ct, myLoc, threat) + bounded_trade = False + if plan is None: + turret_attacks = self._visible_home_turret_attacks(ct) + plan = self._bounded_home_counter_plan( + ct, myLoc, threat, turret_attacks) + bounded_trade = plan is not None + if (plan is None or not home_counter_budget_allows( + ct.get_global_resources(), bounded_trade)): + self._heal_or_approach_core(ct, myLoc) + return + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if ct.can_act() and ct.can_build_gunner(seat, facing): + ct.build_gunner(seat, facing) + + def _may_attack(self, ct: Controller) -> bool: + # Preserve one home-side builder without relying on globally + # interleaved entity IDs. + return not self._is_waller(ct) + + def _owns_late_route_repair( + self, ct: Controller, target: Position) -> bool: + myTeam = ct.get_team() + candidates = [(ct.get_id(), ct.get_position())] + for unitId in ct.get_nearby_units(): + if unitId == ct.get_id(): + continue + if (ct.get_team(unitId) == myTeam + and ct.get_entity_type(unitId) + == EntityType.BUILDER_BOT): + candidates.append((unitId, ct.get_position(unitId))) + excluded = { + encoded - 1 + for encoded in ( + ct.read_store(SLOT_SIEGER_1_ID), + ct.read_store(SLOT_SIEGER_2_ID), + ct.read_store(SLOT_HOME_DEFENDER_ID), + ) + if encoded + } + wallerEncoded = ct.read_store(SLOT_WALLER_ID) + preferred = {wallerEncoded - 1} if wallerEncoded else set() + return choose_route_repairer( + candidates, target, excluded, preferred) == ct.get_id() + + def _recall_is_up(self, ct: Controller) -> bool: + return ct.read_store(SLOT_RECALL) == 1 + + def _answer_recall(self, ct: Controller, myLoc: Position) -> bool: + """Go home - but only when there is nothing better to do here. + + exp_recall made this a hard override and lost 6 points to the parent + (44%, CI 39-49, kills 120-169): it pulled siegers off live seats. v6 + scores retreat at 7-9, deliberately BELOW attack's core-defence ceiling + of 12, so a unit that can build a killing gunner still builds it. This + is that priority, expressed in our branch structure instead of a score. + """ + threat = unpack_position(ct.read_store(SLOT_HOME_THREAT)) + if threat is not None: + self._run_home_defense(ct, myLoc, threat) + return True + if self.mapPf.teamCore is not None: + self._heal_or_approach_core(ct, myLoc) + return True + return False + + def _run_core_siege(self, ct: Controller, myLoc: Position) -> None: + """Preserve direct siege; role 2 counters only if every plan is covered.""" + target = self._enemy_core_target() + if target is None: + return + plans = self._direct_siege_plans(ct, myLoc, target) + if self._sieger_role(ct) != 2: + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + defenders = self._visible_defenders(ct) + unsafe = set() + defender_attacks = [] + for defender in defenders: + attack_tiles = set(ct.get_attackable_tiles_from( + defender[3], defender[5], defender[4])) + unsafe.update(attack_tiles) + defender_attacks.append((defender, attack_tiles)) + safe_plans = [ + plan for plan in plans + if plan[1] not in unsafe and plan[2] not in unsafe + ] + if safe_plans: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan( + ct, myLoc, target, safe_plans) + return + if not direct_plans_all_covered(plans, unsafe): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + + covered = [] + for defender, attack_tiles in defender_attacks: + coverage = sum( + 1 for _, seat, stand, _ in plans + if seat in attack_tiles or stand in attack_tiles + ) + if coverage: + covered.append(( + -coverage, + defender[0], + defender[1], + defender[2], + defender, + )) + if not covered: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + covered.sort() + defender_pos = covered[0][4][3] + if self._friendly_gunner_covers(ct, defender_pos): + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + counter_plan = self._safe_counter_plan( + ct, myLoc, defender_pos, unsafe) + if counter_plan is None: + ct.write_store(SLOT_SIEGER_2_COUNTER_TARGET, 0) + self._execute_direct_siege_plan(ct, myLoc, target, plans) + return + ct.write_store( + SLOT_SIEGER_2_COUNTER_TARGET, pack_position(defender_pos)) + self._execute_siege_plan(ct, myLoc, counter_plan) + + def _direct_siege_plans( + self, ct: Controller, myLoc: Position, target: Position): + core_tiles = [ + Position(target.x + dx, target.y + dy) + for dx in (0, 1) for dy in (0, 1) + ] + plans = [] + for seat in ct.get_nearby_tiles(): + if ct.get_tile_env(seat) != Environment.EMPTY: + continue + if ct.get_tile_building_id(seat) is not None: + continue + firing = None + for facing in DIRECTIONS: + for core_tile in core_tiles: + if ct.can_fire_from( + seat, facing, EntityType.GUNNER, core_tile): + firing = (facing, core_tile) + break + if firing is not None: + break + if firing is None: + continue + facing, core_tile = firing + diagonal = seat.x != core_tile.x and seat.y != core_tile.y + for approach in CARDINALS: + stand = seat.add(approach) + if not (0 <= stand.x < self.mapW and 0 <= stand.y < self.mapH): + continue + if not ct.is_in_vision(stand): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + 0 if diagonal else 1, + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + ) + plans.append((key, seat, stand, facing)) + plans.sort() + return plans + + def _execute_direct_siege_plan( + self, ct: Controller, myLoc: Position, + target: Position, plans) -> None: + if not plans: + if self._recall_is_up(ct) and self._answer_recall(ct, myLoc): + return + self.mapPf.moveTo(ct, target) + return + seat, stand, facing = plans[0][1:] + if (myLoc != stand and self._recall_is_up(ct) + and self._answer_recall(ct, myLoc)): + return # travelling, not shooting - go home instead + self._execute_siege_plan(ct, myLoc, plans[0][1:]) + + def _execute_siege_plan(self, ct: Controller, myLoc: Position, plan) -> None: + seat, stand, facing = plan + if myLoc != stand: + self.mapPf.moveTo(ct, stand) + return + if (ct.can_act() + and ct.get_global_resources() > SIEGE_TITANIUM_FLOOR + and ct.can_build_gunner(seat, facing)): + ct.build_gunner(seat, facing) + + def _visible_defenders(self, ct: Controller): + my_team = ct.get_team() + defenders = [] + for building_id in ct.get_nearby_buildings(): + if ct.get_team(building_id) == my_team: + continue + building_type = ct.get_entity_type(building_id) + if building_type not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + position = ct.get_position(building_id) + defenders.append(( + position.x, + position.y, + building_id, + position, + building_type, + ct.get_direction(building_id), + )) + defenders.sort() + return defenders + + def _safe_counter_plan( + self, ct: Controller, myLoc: Position, + target: Position, unsafe): + best = None + for seat in ct.get_nearby_tiles(): + if (seat in unsafe + or ct.get_tile_env(seat) != Environment.EMPTY + or ct.get_tile_building_id(seat) is not None): + continue + for facing_index, facing in enumerate(DIRECTIONS): + if not ct.can_fire_from( + seat, facing, EntityType.GUNNER, target): + continue + for approach_index, approach in enumerate(CARDINALS): + stand = seat.add(approach) + if stand in unsafe: + continue + if not (0 <= stand.x < self.mapW + and 0 <= stand.y < self.mapH + and ct.is_in_vision(stand)): + continue + if stand != myLoc: + if ct.get_tile_builder_bot_id(stand) is not None: + continue + if not ct.is_tile_passable(stand): + continue + key = ( + abs(myLoc.x - stand.x) + abs(myLoc.y - stand.y), + seat.distance_squared(target), + seat.x, + seat.y, + facing_index, + approach_index, + ) + if best is None or key < best[0]: + best = (key, seat, stand, facing) + if best is None: + return None + return best[1:] + + def wallDuty(self, ct: Controller, myLoc: Position) -> bool: + """One early builder's bounded job: wall the enemy-side ring with + core-facing conveyors, then hand back to normal life (returns False + when done). Home-side ring stays open — those are the core's only + heal positions (full seal gated 29%).""" + if getattr(self, "_dutyDone", False): + return False + tc = self.mapPf.teamCore + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return False + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + todo = [] + for x in range(cx - 1, cx + 3): + for y in range(cy - 1, cy + 3): + if (x, y) in foot: + continue + if not (0 <= x < self.mapW and 0 <= y < self.mapH): + continue + if abs(x - eg.x) + abs(y - eg.y) > cd: + continue # home side stays open + p = Position(x, y) + if not ct.is_in_vision(p): + todo.append(p) + continue + if ct.get_tile_building_id(p) is not None: + continue + if self.mapPf.getTileEnv(p) in (1, 2, 3): + continue + todo.append(p) + # FULL enemy-side ring — measured truth: 3-tile micro-duty just + # displaced the wave one tile over (8 identical t62 kills); only + # denying the whole half breaks it (skerry 69%, krb overall 85%) + todo.sort(key=lambda q: abs(q.x - eg.x) + abs(q.y - eg.y)) + if not todo: + self._dutyDone = True + return False + if ct.get_global_resources() < 15: + return True # hold near home while poor rather than wandering + if ct.can_act(): + for p in todo: + if abs(myLoc.x - p.x) + abs(myLoc.y - p.y) != 1: + continue + tgt = None + for d in CARDINALS: + n = p.add(d) + if (n.x, n.y) in foot: + tgt = n + break + if tgt is None: + # ring corner: chain into an edge-ring conveyor that + # already exists (never create a dead end — trunk trap) + for d in CARDINALS: + n = p.add(d) + if not (0 <= n.x < self.mapW and 0 <= n.y < self.mapH): + continue + nId = ct.get_tile_building_id(n) if ct.is_in_vision(n) else None + if (nId is not None and ct.get_team(nId) == ct.get_team() + and ct.get_entity_type(nId) == EntityType.CONVEYOR): + tgt = n + break + if tgt is None: + continue + f = p.direction_to(tgt) + if f in CARDINALS and ct.can_build_conveyor(p, f): + ct.build_conveyor(p, f) + return True + todo.sort(key=lambda q: myLoc.distance_squared(q)) + self.mapPf.moveTo(ct, todo[0]) + return True + + def passiveWall(self, ct: Controller, myLoc: Position): + if not ct.can_act(): + return # this turn's action went to the real job + tc = self.mapPf.teamCore + if tc is None or ct.get_global_resources() < 60: + return + if self._wallSet is None: + cx, cy = tc.x, tc.y + foot = {(cx + a, cy + b) for a in (0, 1) for b in (0, 1)} + eg = self._enemy_core_target() + if eg is None: + return + cd = abs(cx + 0.5 - eg.x) + abs(cy + 0.5 - eg.y) + s = set() + for x in range(cx - 1, cx + 3): # enemy-side ring + ties + for y in range(cy - 1, cy + 3): # (home side stays open: + if (x, y) in foot: # spawn exit + the only + continue # core-heal positions) + if abs(x - eg.x) + abs(y - eg.y) <= cd: + s.add((x, y)) + for o in (0, 1): # cardinal rays d2-3 + for d in (2, 3): + s.update([(cx - d, cy + o), (cx + 1 + d, cy + o), + (cx + o, cy - d), (cx + o, cy + 1 + d)]) + for a in (0, 1): # 2-step diagonals (8-way + for b in (0, 1): # facing since 2.3) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + s.add((cx + a + 2 * dx, cy + b + 2 * dy)) + self._wallSet = {(x, y) for (x, y) in s + if 0 <= x < self.mapW and 0 <= y < self.mapH} + foot = {(tc.x + a, tc.y + b) for a in (0, 1) for b in (0, 1)} + for d in CARDINALS: + wp = myLoc.add(d) + if (wp.x, wp.y) not in self._wallSet: + continue + if not ct.is_in_vision(wp) or ct.get_tile_building_id(wp) is not None: + continue + if self.mapPf.getTileEnv(wp) in (1, 2, 3): # ore/wall/blocked + continue + # face coreward, and NEVER build a dead end: output must be the + # core or an existing own conveyor (a dead-end wall conveyor + # swallows every harvest chain routed into it) + target = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if (nxt.x, nxt.y) in foot: + target = nxt + break + if target is None: + best = None + for d2 in CARDINALS: + nxt = wp.add(d2) + if not (0 <= nxt.x < self.mapW and 0 <= nxt.y < self.mapH): + continue + if not ct.is_in_vision(nxt): + continue + nId = ct.get_tile_building_id(nxt) + if nId is None or ct.get_team(nId) != ct.get_team(): + continue + if ct.get_entity_type(nId) != EntityType.CONVEYOR: + continue + dd = abs(nxt.x - tc.x) + abs(nxt.y - tc.y) + if best is None or dd < best[0]: + best = (dd, nxt) + if best is not None: + target = best[1] + if target is None: + continue + facing = wp.direction_to(target) + if facing in CARDINALS and ct.can_build_conveyor(wp, facing): + ct.build_conveyor(wp, facing) + return + def runGunner (self, ct: Controller): + curTarget = ct.get_gunner_target() + myDir = ct.get_direction() + myPos = ct.get_position() + myTeam = ct.get_team() + if curTarget is not None: + targetId = ct.get_tile_building_id(curTarget) + bbId = ct.get_tile_builder_bot_id(curTarget) + if bbId is not None and ct.get_team(bbId) == myTeam: + return # dont kill your own bot + enemy_building = ( + targetId is not None and ct.get_team(targetId) != myTeam) + enemy_builder = ( + bbId is not None and ct.get_team(bbId) != myTeam) + if enemy_building or enemy_builder: + if ct.can_fire(curTarget): + ct.fire(curTarget) + return + if ct.get_global_resources() > 60: + bestScore = 0 + bestDir = myDir # so you only rotate when you need to + for d in DIRECTIONS: + curScore = 0 + for tile in ct.get_attackable_tiles_from(myPos, d, EntityType.GUNNER): + tileId = ct.get_tile_building_id(tile) + bbId = ct.get_tile_builder_bot_id(tile) + if tileId is not None and ct.get_team(tileId) != myTeam: + tType = ct.get_entity_type(tileId) + if tType in [EntityType.GUNNER, EntityType.SENTINEL]: + curScore += 10 + elif tType == EntityType.CORE: + # A core-capable gun must never rotate back into + # mid-map brawling. + curScore += 1000 + elif tType in [EntityType.LAUNCHER, EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + curScore += 4 + else: + curScore += 1 + # Builder bots occupy a separate tile layer. The parent + # accidentally scored them only when an enemy building was + # on the same tile, so lone delivery specialists were + # invisible to gunner rotation. + if bbId is not None and ct.get_team(bbId) != myTeam: + curScore += 2 + if curScore > bestScore: + bestScore = curScore + bestDir = d + if bestDir != myDir: + if ct.can_rotate(bestDir): + ct.rotate(bestDir) + + + def runBestState(self, ct: Controller, myLoc: Position): + nearbyUnits = ct.get_nearby_entities() # both builder bots and buildings + myTeam = ct.get_team() + + # attack, max score of 10 + attackScore = 0 + attackPos = None + if self.attackBan == 0: + if ct.get_global_resources() > 120 and self._may_attack(ct): + for b in nearbyUnits: # looks at nearby enemies, and scored on entity type and distance + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam != myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.GUNNER, EntityType.SENTINEL, EntityType.CORE]: + buildingScore = 10 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 8 + elif bType == EntityType.BUILDER_BOT: + buildingScore = 2 + else: + buildingScore = 1 + else: + continue + dist = myLoc.distance_squared(bPos) + buildingScore = buildingScore * (1 - dist/40) + if buildingScore > attackScore: + attackScore = buildingScore + attackPos = bPos # no need to worry about this not being initialized, as it needs buildingScore > attackScore, so there must be a position + for b in ct.get_nearby_buildings(5): + if ct.get_entity_type(b) == EntityType.GUNNER and ct.get_team(b) == myTeam: + attackScore = 0 + self.attackBan = 4 + (ct.get_id() % 8) + break + else: + self.attackBan -= 1 + + # heal, max score of 8 + healScore = 0 + healPos = None + for b in nearbyUnits: # scored on how low the unit is, distance, and entity type + bTeam = ct.get_team(b) + buildingScore = 0 + if bTeam == myTeam: + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + if bType in [EntityType.CORE, EntityType.BUILDER_BOT]: # dont waste an entire state on just healing yourself + buildingScore = 8 + elif bType in [EntityType.GUNNER, EntityType.SENTINEL]: + buildingScore = 6 + elif bType in [EntityType.CONVEYOR, EntityType.HARVESTER, EntityType.SPLITTER]: + buildingScore = 4 + else: + buildingScore = 2 + else: + continue + dist = myLoc.distance_squared(bPos) + cHP = ct.get_hp(b) + maxHP = ct.get_max_hp(b) + mHP = maxHP - cHP + buildingScore = buildingScore * (1 - dist/120) * (mHP/maxHP) + if buildingScore > healScore: + healScore = buildingScore + healPos = bPos + + # route, max score of 6 + routeScore = 0 # orphan harvesters + unfinished conveyor chains + routePos = None + routeDir = None + routeLate = False + mapW = self.mapW + mapH = self.mapH + teamCore = self.mapPf.teamCore + if teamCore is not None: + for b in nearbyUnits: + bScore = 0 + bPos = ct.get_position(b) + bType = ct.get_entity_type(b) + bDir = None + endTile = None + if bType == EntityType.HARVESTER and myLoc.distance_squared(bPos) < 16: # max score of 5 to prioritize cotninueing paths + noTeamConv = True + workingSpots = [] + for possibleDir in CARDINALS: # would have named it dir, but thats not allowed + endTile = bPos.add(possibleDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + workingSpots.append(endTile) + elif ct.get_team(eId) == myTeam and ct.get_entity_type(eId) == EntityType.CONVEYOR: + noTeamConv = False + if noTeamConv and len(workingSpots) > 0: + workingSpots.sort(key=lambda pos: pos.distance_squared(teamCore)) + bScore = max(1.6, 5 * max(0, (1 - (workingSpots[0].distance_squared(teamCore) / 100))) * max(0, (1 - myLoc.distance_squared(bPos)/60))) + bDir = Direction.CENTRE + endTile = workingSpots[0] + elif bType == EntityType.CONVEYOR: + if ct.get_team(b) == myTeam: + bDir = ct.get_direction(b) + endTile = bPos.add(bDir) + if 0 <= endTile.x < mapW and 0 <= endTile.y < mapH and ct.is_in_vision(endTile) and ct.is_tile_passable(endTile): + eId = ct.get_tile_building_id(endTile) + if eId is None: + bScore = max(2, 6 * max(0, (1 - (endTile.distance_squared(teamCore) / 120))) * max(0, (1 - myLoc.distance_squared(bPos)/40))) + if bScore > routeScore: + routeScore = bScore + routePos = endTile + routeDir = bDir + + # harvest, max score of 3 + harvestScore = 0 + harvestPos = None + if teamCore is not None: + for tile in ct.get_nearby_tiles(): + if self.mapPf.getTileEnv(tile) == 1: # since it checks all nearby tiles before choosing state, this is fine + if ct.get_tile_building_id(tile) is None: + dist = teamCore.distance_squared(tile) + tileScore = max(1.2, 3 * (1 - dist/160) * (1 - myLoc.distance_squared(tile)/120)) + if tileScore > harvestScore: + harvestPos = tile + harvestScore = tileScore + + + # explore, max score of 1 + exploreScore = 0 + if ct.get_current_round() < 12: + explorePos = self.initTarget + exploreScore = 1 + else: + explorePos = self.mapPf.returnUnvisited(ct, myLoc) + if explorePos is not None: + exploreScore = 1 + else: + exploreScore = 0.4 # exploring isnt as important then + # Preserve the parent's exact transition. Only after round 80 may one + # nearest non-specialist finish a short route to an already-built mine. + currentRound = ct.get_current_round() + if currentRound >= ECON_CUTOFF: + harvestScore = 0 + routeScore = 0 + routePos = None + routeLinks = 4096 + if currentRound >= LATE_ROUTE_REPAIR_START: + for _, endTile, started, remainingLinks in ( + self.mapPf.lateEconomyRouteCandidates(ct)): + ceiling = 6 if started else 5 + floor = 2 if started else 1.6 + candidateScore = max( + floor, + ceiling + * max(0, 1 - endTile.distance_squared(teamCore) / 120) + * max(0, 1 - myLoc.distance_squared(endTile) / 60), + ) + if candidateScore > routeScore: + routeScore = candidateScore + routePos = endTile + routeLinks = remainingLinks + if (routePos is None + or not late_route_repair_allowed( + currentRound, ct.get_global_resources(), + routeLinks) + or not self._is_waller(ct) + or healScore > 0 + or not self._owns_late_route_repair(ct, routePos)): + routeScore = 0 + routePos = None + else: + routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP) + routeDir = Direction.CENTRE + routeLate = True + stateScores = [attackScore, healScore, harvestScore, routeScore, exploreScore] + stateScores.sort(key=lambda score: score, reverse=True) + bestScore = stateScores[0] + if bestScore == attackScore: + self.attack(ct, attackPos) + elif bestScore == healScore: + self.heal(ct, healPos) + elif bestScore == routeScore: + if routeLate: + self.mapPf.routeLateConveyor(ct, routePos) + else: + self.route(ct, routePos, routeDir) + elif bestScore == harvestScore: + self.harvest(ct, harvestPos) + else: + self.explore(ct, explorePos) + def attack(self, ct: Controller, attackPos: Position): + myLoc = ct.get_position() + myTeam = ct.get_team() + mapW = self.mapW + mapH = self.mapH + for d in CARDINALS: + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5 and 0 <= gunnerSpot.x < mapW and 0 <= gunnerSpot.y < mapH: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is None: + gunnerDir = gunnerSpot.direction_to(attackPos) + if ct.can_build_gunner(gunnerSpot, gunnerDir): + ct.build_gunner(gunnerSpot, gunnerDir) + return # you might be able to build next turn tho, so leave as is + for d in CARDINALS: # try destorying after you exhaust all possible build opportunities + gunnerSpot = myLoc.add(d) + dist = attackPos.distance_squared(gunnerSpot) + if dist < 10 and dist != 5: + spotId = ct.get_tile_building_id(gunnerSpot) + if spotId is not None: + spotTeam = ct.get_team(spotId) + spotType = ct.get_entity_type(spotId) + if spotTeam == myTeam and spotType in [EntityType.BARRIER, EntityType.CONVEYOR]: + if ct.can_destroy(gunnerSpot): + ct.destroy(gunnerSpot) + return + self.mapPf.moveTo(ct, attackPos) + + def heal(self, ct: Controller, healPos: Position): + myLoc = ct.get_position() + if myLoc == healPos and ct.get_hp() < 40: # this means you are low, so run + self.mapPf.moveTo(ct, self.mapPf.teamCore) + return # act-xor-move: fleeing IS this turn, healing would no-op + if ct.can_heal(healPos): + ct.heal(healPos) + else: + self.mapPf.moveTo(ct, healPos) + + def route(self, ct: Controller, routePos: Position, routeDir: Direction): + self.mapPf.routeConveyor(ct, routePos) + def harvest(self, ct: Controller, harvestPos: Position): + myLoc = ct.get_position() + dist = harvestPos.distance_squared(myLoc) + if dist > 2: + self.mapPf.moveTo(ct, harvestPos) + return + elif dist == 2: + for d in CARDINALS: + nextPos = myLoc.add(d) + if nextPos.distance_squared(harvestPos) == 1 and ct.can_move(d): + ct.move(d) + break + return + elif dist == 0: + for d in CARDINALS: + if ct.can_move(d): + ct.move(d) + break + return + if ct.can_build_harvester(harvestPos): + ct.build_harvester(harvestPos) + def explore(self, ct: Controller, explorePos: Position): + myLoc = ct.get_position() + # FINISHER: nothing in vision, bank full, game aging -> march at the + # mirrored enemy core instead of sightseeing. The brawler reflex + # (attack state) takes over the moment their stuff enters vision, so + # this converts slow suffocation wins into core kills and rescues + # would-be tiebreak games. + if (ct.get_current_round() > 50 and ct.get_global_resources() > 150 + and self.mapPf.teamCore is not None and self._may_attack(ct)): + explorePos = self._enemy_core_target() + elif explorePos is None: + corners = [Position(0, 0), Position(self.mapW - 1, 0), Position(0, self.mapH - 1), Position(self.mapW - 1, self.mapH - 1)] + explorePos = corners[ct.get_current_round() // 20 % 4] + self.mapPf.moveTo(ct, explorePos) diff --git a/bots/exp_waller_route_reserve/mapPathfinding.py b/bots/exp_waller_route_reserve/mapPathfinding.py new file mode 100644 index 0000000..038f3f3 --- /dev/null +++ b/bots/exp_waller_route_reserve/mapPathfinding.py @@ -0,0 +1,647 @@ +"""Starter bot - a simple example to demonstrate usage of the Controller API. + +Each unit gets its own Player instance; the engine calls run() once per round. +Use Controller.get_entity_type() to branch on what kind of unit you are. +""" + +import random + +from fcode import Controller, Direction, EntityType, Environment, Position + +DIRECTIONS = [d for d in Direction if d != Direction.CENTRE] +CARDINALS = [Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST] + +openCost = 1 +barrierCost = 8 # to be implemented later + + +def core_footprint_positions(core: Position): + return { + Position(core.x + dx, core.y + dy) + for dx in (0, 1) for dy in (0, 1) + } + + +def proven_connected_conveyors(conveyor_directions, core_tiles): + """Return only conveyors whose output-facing chain reaches the core.""" + connected = set() + changed = True + while changed: + changed = False + for position, facing in conveyor_directions.items(): + if position in connected: + continue + if position.add(facing) in core_tiles | connected: + connected.add(position) + changed = True + return connected + + +def harvester_chain_roots(harvester, conveyor_directions): + return [ + position + for position in (harvester.add(direction) for direction in CARDINALS) + if position in conveyor_directions + and position.add(conveyor_directions[position]) != harvester + ] + + +def trace_conveyor_chain(root, conveyor_directions, core_tiles, connected): + position = root + visited = set() + for _ in range(len(conveyor_directions) + 1): + if position in visited: + return "cycle", position + visited.add(position) + facing = conveyor_directions.get(position) + if facing is None: + return "dead_end", position + output = position.add(facing) + if output in core_tiles or output in connected: + return "connected", None + if output not in conveyor_directions: + return "dead_end", output + position = output + return "cycle", None + + +tileTypes = { # limited to 4 types to maximize caching ability + Environment.EMPTY: 0, # Only counts if they are passable, or have a team barrier on them + Environment.ORE_TITANIUM: 1, # Only counts if they dont have a harvester on them + Environment.WALL: 2, + 'Unpassable': 3 # have an unpassable non team barrier building or are threat from turrets, +} +tileTypes2 = { + "Wall": 0, + "Empty": 1, # no building + "EETurret": 2, # enemy turret + "EEConveyor": 3, # enemy conveyor + "EEBlocker": 4, # enemy launcher/barrier + "ETTurret": 5, # team turret + "ETConveyor": 6, # team conveyor + "ETBlocker": 7, # team launcher/barrier + "ORE": 8, # no building + "OETurret": 9, # enemy turret (on ore) + "OEConveyor": 10, # enemy conveyor (on ore) + "OEBlocker": 11, # enemy launcher/barrier (on ore) + "OEHarvester": 12, # enemy harvester + "OTTurret": 13, # team turret (on ore) + "OTBlocker": 14, # team launcher/barrier (on ore) + "OTHarvester": 15 # team harvester +} # tileTypes2 would be really cool to implement, but imo not worth the time right now +# shared array. 0: num bots spawned 1-6: new tiles from that bot 7: the map symmetry + +class MapPathfinder: + def __init__(self): + self.fullMap = None # bot's local version of the full map + self.newTiles = [] # shared publicly so all bots have info + self.seenTiles = None # used to get self.newTiles + self.myNum = -1 # every builder bot gets its own unique number used to write to the shared array, and organize roles (1, 2, 3, ...) + self.teamCore = None + self.distMap = None + self.conveyorMap = None + self.mapW = None + self.mapH = None + self.stuckTurns = 0 + self.mapChanged = True + self.prevTarget = None + self.randDir = Direction.NORTH + self.knownTeamConveyors = {} + self.knownTeamHarvesters = set() + def setupMap(self, ct: Controller): + self.mapChanged = False + curRound = ct.get_current_round() + if self.teamCore is None: + for i in ct.get_nearby_buildings(): + if ct.get_entity_type(i) == EntityType.CORE and ct.get_team(i) == ct.get_team(): + self.teamCore = ct.get_position(i) + + if self.myNum == -1: + self.myNum = ct.read_store(0) + if self.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + if self.distMap is None: + self.distMap = [[4096 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[4096, 'stuck'] for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.fullMap is None: + self.fullMap = [[-1 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.seenTiles is None: + self.seenTiles = [[False for _ in range(self.mapH)] for _ in range(self.mapW)] + self.getNewTiles(ct) + self.shareTiles(ct, curRound) + self.updateBoard(ct) + def checkPassable (self, ct: Controller, tile: Position): # Only team barriers, or empty tiles are passbale + tileId = ct.get_tile_building_id(tile) + if tileId is not None and not ct.is_tile_passable(tile): + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team() or tType != EntityType.BARRIER: + return False + return True + def getNewTiles (self, ct: Controller): + for tile in ct.get_nearby_tiles(): + x = tile.x + y = tile.y + tileEnv = ct.get_tile_env(tile) + if not self.checkPassable(ct, tile) and tileEnv != Environment.WALL: # Walls clearly dont have buildings so need that + tileEnv = 'Unpassable' + tileType = tileTypes.get(tileEnv) + if self.seenTiles[x][y] == False or self.fullMap[x][y] != tileType: + self.seenTiles[x][y] = True + self.newTiles.append([Position(x, y), tileType]) + self.mapChanged = True + self.fullMap[x][y] = tileType + + def observeEconomyTile(self, ct: Controller, tile: Position): + """Refresh facing memory only for the currently visible tile.""" + tileId = ct.get_tile_building_id(tile) + ownType = None + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + ownType = ct.get_entity_type(tileId) + if ownType == EntityType.CONVEYOR: + self.knownTeamConveyors[tile] = ct.get_direction(tileId) + else: + self.knownTeamConveyors.pop(tile, None) + if ownType == EntityType.HARVESTER: + self.knownTeamHarvesters.add(tile) + else: + self.knownTeamHarvesters.discard(tile) + + def refreshEconomyGraph(self, ct: Controller): + """Refresh graph state only when the designated repairer needs it.""" + for tile in ct.get_nearby_tiles(): + self.observeEconomyTile(ct, tile) + + def connectedConveyors(self): + if self.teamCore is None: + return set() + return proven_connected_conveyors( + self.knownTeamConveyors, + core_footprint_positions(self.teamCore), + ) + + def routeLinkEstimate(self, position: Position, connected=None): + if self.teamCore is None: + return 4096 + if connected is None: + connected = self.connectedConveyors() + targets = core_footprint_positions(self.teamCore) | set(connected) + return max(1, min( + abs(position.x - target.x) + abs(position.y - target.y) + for target in targets + )) + + def lateEconomyRouteCandidates(self, ct: Controller): + """Missing links traced from built friendly harvesters, never walls.""" + connected = self.connectedConveyors() + coreTiles = core_footprint_positions(self.teamCore) + candidates = [] + for harvester in sorted( + self.knownTeamHarvesters, key=lambda p: (p.x, p.y)): + roots = harvester_chain_roots( + harvester, self.knownTeamConveyors) + traces = [ + trace_conveyor_chain( + root, self.knownTeamConveyors, coreTiles, connected) + for root in roots + ] + if any(status == "connected" for status, _ in traces): + continue + ends = { + end for status, end in traces + if status in ("dead_end", "cycle") and end is not None + } + if not roots: + ends = {harvester.add(direction) for direction in CARDINALS} + for end in sorted(ends, key=lambda p: (p.x, p.y)): + if not (0 <= end.x < self.mapW and 0 <= end.y < self.mapH): + continue + if not ct.is_in_vision(end): + continue + endId = ct.get_tile_building_id(end) + if endId is not None: + if ct.get_team(endId) != ct.get_team(): + continue + endType = ct.get_entity_type(endId) + if endType == EntityType.CONVEYOR: + if end in connected: + continue + elif endType != EntityType.BARRIER: + continue + occupyingBuilder = ct.get_tile_builder_bot_id(end) + if (occupyingBuilder is not None + and ct.get_team(occupyingBuilder) != ct.get_team()): + continue + if (occupyingBuilder is None + and not ct.is_tile_passable(end)): + continue + candidates.append(( + harvester, + end, + bool(roots), + self.routeLinkEstimate(end, connected), + )) + return candidates + def shareTiles(self, ct: Controller, curRound): + boundedRound = curRound % ct.read_store(0) + 1 # to bound it from 1 to n + if boundedRound == self.myNum: # means your turn to share in the shared array if the mod is correct + + # start by sorting newTiles by the 16 tiles farthest away from your core + self.newTiles.sort(key=lambda t: t[0].distance_squared(self.teamCore), + reverse=True + ) + # convert each tile into a 12 bit integer (if theres less tiles, just send all 1s to fill in the remaining space (so it points to an out of bounds tile) + combined = 0 + needToRemove = [] + for i in range(16): + if i < len(self.newTiles): + cachedPos, cachedType = self.newTiles[i] + needToRemove.append(self.newTiles[i]) + val = ((cachedPos.x & 0x1F) << 7) | ((cachedPos.y & 0x1F) << 2) | (cachedType & 0x3) + else: + val = 0xFFF + combined = (combined << 12) | val # combine all of them to form a 196 bit integer + + # split that numebr into 6 32 bit integers, and write them to the shared array + for i in range(6): + part = (combined >> (32 * i)) & 0xFFFFFFFF + ct.write_store(6 - i, part) + for i in needToRemove: + self.newTiles.remove(i) + def updateBoard (self, ct: Controller): + # read the 6 32 bit integers, and combine them into 1 196 bit one. + combined = 0 + for i in range(1, 7): + part = ct.read_store(i) + combined = (combined << 32) | part + # split that into 16 12 bit integers. + # retrive the X, and Y and the cached value for each integer. + # if X > mapW or Y > mapH, then pass that one + # update your table, adn check if the tile is in you newTiles. If it is remove it + for i in range(16): + val = (combined >> (12 * (15 - i))) & 0xFFF + + if val == 0xFFF: + continue + + x = (val >> 7) & 0x1F + y = (val >> 2) & 0x1F + t_type = val & 0x3 + + if self.mapW is not None and (x >= self.mapW or y >= self.mapH): # all 1s means that no actual one to share + continue + + ct.draw_indicator_line(Position(x, y), ct.get_position(), 20, 140, 200) + + if not self.seenTiles[x][y] or self.fullMap[x][y] != t_type: + self.seenTiles[x][y] = True + self.fullMap[x][y] = t_type + self.mapChanged = True + + self.newTiles = [t for t in self.newTiles if not (t[0].x == x and t[0].y == y)] # you dont want to broadcast the same position twice + + def tileCost(self, ct: Controller, tile: Position) -> int | None: + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal > 1: # either wall or unpassable + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + return barrierCost + elif ct.is_tile_passable(tile) == False: + return 4096 + return openCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 8 + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 4096 + + buckets = [[] for _ in range(maxCost)] + buckets[0].append(targetLoc) + self.distMap[targetLoc.x][targetLoc.y] = 0 + + myLoc = ct.get_position() + + myNeighbors = [] + for d in CARDINALS: + myNeighbors.append(myLoc.add(d)) + + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + + if self.distMap[cur.x][cur.y] < dist: + continue + + if (cur.x, cur.y) in myNeighbors: + hitBucket = True + continue + + for d in CARDINALS: + nextTile = cur.add(d) + nextX = nextTile.x + nextY = nextTile.y + if 0 <= nextX < w and 0 <= nextY < h: + stepCost = self.tileCost(ct, nextTile) + if stepCost is not None: + newDist = dist + stepCost + if newDist < maxCost and newDist < self.distMap[nextX][nextY]: + self.distMap[nextX][nextY] = newDist + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def conveyorTileCost(self, ct: Controller, tile: Position): + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal == 2 or cachedVal == 3: + return [None, 'stuck'] + + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam != ct.get_team(): + return [None, 'stuck'] # enemy building + if tType == EntityType.CORE: + return [0, 'done'] + if tType == EntityType.CONVEYOR: + if ct.get_stored_resource(tileId) is not None: + return [40, 'done'] # loaded + return [8, 'working'] # empty + if tType == EntityType.BARRIER: + return [barrierCost, 'working'] + return [None, 'stuck'] # dont break other stuff + return [1, 'working'] + + def fillConveyorDistTable(self, ct: Controller, curEnd: Position): + myTeam = ct.get_team() + myLoc = ct.get_position() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 # because path ends when you go to a full conveyor + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + tL = self.teamCore + bL = self.teamCore.add(Direction.SOUTH) + tR = self.teamCore.add(Direction.EAST) + bR = self.teamCore.add(Direction.SOUTH).add(Direction.EAST) + corners = [tL, bL, tR, bR] + buckets[0].append(tL) + buckets[0].append(tR) + buckets[0].append(bL) + buckets[0].append(bR) + self.conveyorMap[tL.x][tL.y] = [0, 'done'] + self.conveyorMap[tR.x][tR.y] = [0, 'done'] + self.conveyorMap[bR.x][bR.y] = [0, 'done'] + self.conveyorMap[bL.x][bL.y] = [0, 'done'] + corners.sort(key=lambda corner: corner.distance_squared(myLoc)) + closestCorner = corners[0] + for b in ct.get_nearby_buildings(): + bPos = ct.get_position(b) + bTeam = ct.get_team(b) + bType = ct.get_entity_type(b) + if bTeam == myTeam and bType == EntityType.CONVEYOR: + cost = 8 if ct.get_stored_resource(b) is None else 40 + if bPos.distance_squared(closestCorner) > myLoc.distance_squared(closestCorner): + cost += 24 + if cost < self.conveyorMap[bPos.x][bPos.y][0]: + self.conveyorMap[bPos.x][bPos.y] = [cost, 'done'] + buckets[cost].append(bPos) + for dist in range(maxCost): + bucket = buckets[dist] + hitBucket = False + while bucket: + cur = bucket.pop() + if self.conveyorMap[cur.x][cur.y][0] < dist: + continue + if cur == curEnd: + hitBucket = True + continue + for d in CARDINALS: + nextTile = cur.add(d) + nx, ny = nextTile.x, nextTile.y + if 0 <= nx < w and 0 <= ny < h: + cost, status = self.conveyorTileCost(ct, nextTile) + if status != 'stuck': + newDist = dist + cost + if newDist < maxCost and newDist < self.conveyorMap[nx][ny][0]: + self.conveyorMap[nx][ny] = [newDist, status] + buckets[newDist].append(nextTile) + if hitBucket: + return dist + return 4096 + + def routeConveyor(self, ct: Controller, curEnd: Position): + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 4096 + bestNextDir = None + for d in CARDINALS: + nextPos = curEnd.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.conveyorMap[nextPos.x][nextPos.y][0] + if posDist < bestDist: + bestNextDir = d + bestDist = posDist + if bestNextDir is None: + return # ur cooked just give up + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(curEnd): + ct.destroy(curEnd) + return + if ct.can_build_conveyor(curEnd, bestNextDir): + ct.build_conveyor(curEnd, bestNextDir) + else: + self.moveTo(ct, curEnd) + + def lateConveyorTileCost(self, ct: Controller, tile: Position, connected): + """Late repair may merge only into a proven, currently empty trunk.""" + cachedVal = self.fullMap[tile.x][tile.y] + if cachedVal in (2, 3): + return None + if tile in self.knownTeamConveyors: + if tile not in connected: + return None + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + return None + return 0 + if ct.is_in_vision(tile): + tileId = ct.get_tile_building_id(tile) + if tileId is not None: + if ct.get_team(tileId) != ct.get_team(): + return None + tileType = ct.get_entity_type(tileId) + if tileType == EntityType.CORE: + return 0 + if tileType == EntityType.BARRIER: + return barrierCost + return None + return 1 + + def fillLateConveyorDistTable(self, ct: Controller, curEnd: Position): + connected = self.connectedConveyors() + w, h = self.mapW, self.mapH + maxCost = (w + h) * barrierCost + 48 + for x in range(w): + for y in range(h): + self.conveyorMap[x][y] = [maxCost, 'stuck'] + buckets = [[] for _ in range(maxCost)] + coreTiles = core_footprint_positions(self.teamCore) + for position in coreTiles: + self.conveyorMap[position.x][position.y] = [0, 'done'] + buckets[0].append(position) + for position in connected: + if ct.is_in_vision(position): + tileId = ct.get_tile_building_id(position) + if (tileId is None + or ct.get_team(tileId) != ct.get_team() + or ct.get_entity_type(tileId) != EntityType.CONVEYOR + or ct.get_stored_resource(tileId) is not None): + continue + self.conveyorMap[position.x][position.y] = [8, 'done'] + buckets[8].append(position) + for distance in range(maxCost): + bucket = buckets[distance] + reached = False + while bucket: + current = bucket.pop() + if self.conveyorMap[current.x][current.y][0] < distance: + continue + if current == curEnd: + reached = True + continue + for direction in CARDINALS: + neighbor = current.add(direction) + if not (0 <= neighbor.x < w and 0 <= neighbor.y < h): + continue + step = ( + 0 if neighbor == curEnd + else self.lateConveyorTileCost( + ct, neighbor, connected) + ) + if step is None: + continue + newDistance = distance + step + if (newDistance < maxCost + and newDistance + < self.conveyorMap[neighbor.x][neighbor.y][0]): + self.conveyorMap[neighbor.x][neighbor.y] = [ + newDistance, 'working'] + buckets[newDistance].append(neighbor) + if reached: + return distance + return 4096 + + def routeLateConveyor(self, ct: Controller, curEnd: Position): + """Build one verified late-repair link; parent routing is untouched.""" + self.fillLateConveyorDistTable(ct, curEnd) + best = None + for direction in CARDINALS: + neighbor = curEnd.add(direction) + if not (0 <= neighbor.x < self.mapW + and 0 <= neighbor.y < self.mapH): + continue + distance = self.conveyorMap[neighbor.x][neighbor.y][0] + key = (distance, direction.value) + if best is None or key < best[0]: + best = (key, direction) + if best is None or best[0][0] >= 4096: + return + tileId = ct.get_tile_building_id(curEnd) + if tileId is not None and ct.get_team(tileId) == ct.get_team(): + tileType = ct.get_entity_type(tileId) + if (tileType in (EntityType.BARRIER, EntityType.CONVEYOR) + and curEnd not in self.connectedConveyors() + and ct.can_destroy(curEnd)): + ct.destroy(curEnd) + return + direction = best[1] + if ct.can_build_conveyor(curEnd, direction): + ct.build_conveyor(curEnd, direction) + else: + self.moveTo(ct, curEnd) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + + if self.mapChanged or self.prevTarget is None or self.prevTarget != target: + self.fillInDistTable(ct, target) + self.prevTarget = target + bestDist = 4096 + bestDir = None + for d in CARDINALS: + nextPos = myLoc.add(d) + if 0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH: + posDist = self.distMap[nextPos.x][nextPos.y] + tileId = ct.get_tile_building_id(nextPos) + if posDist < bestDist: + if ct.can_move(d): + bestDir = d + bestDist = posDist + elif tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + bestDir = d + bestDist = posDist + if bestDir is not None: + nextPos = myLoc.add(bestDir) + tileId = ct.get_tile_building_id(nextPos) + if tileId is not None: + tTeam = ct.get_team(tileId) + tType = ct.get_entity_type(tileId) + if tTeam == ct.get_team() and tType == EntityType.BARRIER: + if ct.can_destroy(nextPos): + ct.destroy(nextPos) + else: + return # dont want to increase stuckTurns for no reason + if bestDir is not None and ct.can_move(bestDir): + ct.move(bestDir) + self.stuckTurns = 0 + else: + self.stuckTurns += 1 + if self.stuckTurns > 2 + (ct.get_id() % 8): + movableDirs = [] + for i in CARDINALS: + if ct.can_move(i): + movableDirs.append(i) + if len(movableDirs) > 0: + ct.move(random.choice(movableDirs)) + + def returnUnvisited(self, ct: Controller, myLoc: Position): + unvisitedPositions = [] + for x in range(self.mapW): + for y in range(self.mapH): + if not self.seenTiles[x][y]: + unvisitedPositions.append(Position(x, y)) + unvisitedPositions.sort(key=lambda pos: pos.distance_squared(myLoc)) + if len(unvisitedPositions) > 0: + return unvisitedPositions[0] + return None + def getTileEnv (self, tile: Position): + return self.fullMap[tile.x][tile.y] diff --git a/bots/exp_waller_route_reserve/symmetry.py b/bots/exp_waller_route_reserve/symmetry.py new file mode 100644 index 0000000..f3001b0 --- /dev/null +++ b/bots/exp_waller_route_reserve/symmetry.py @@ -0,0 +1,116 @@ +"""Map-symmetry prediction (Khaos blueprint Tier A, section 3). + +Florent maps are symmetric by reflection or rotation. By comparing observed +static terrain (wall / ore / empty) against its mirror under each hypothesis we +eliminate the inconsistent ones, then predict the enemy core corner from the +surviving symmetry. Default to ROTATIONAL while ambiguous (Khaos default). + +fullMap tile codes (from main.py): + -1 unseen · 0 empty/passable · 1 ore · 2 wall · 3 building/unpassable(dynamic) + +Only codes {0,1,2} are static terrain we can trust for symmetry; code 3 hides +the underlying terrain (a harvester sits on ore), so we skip those tiles. +""" + +# symmetry ids, in preference order (rotational first, Khaos default) +ROTATIONAL = 0 # 180 rotation +HORIZONTAL = 1 # flip x +VERTICAL = 2 # flip y +_ALL = (ROTATIONAL, HORIZONTAL, VERTICAL) + + +def _terrain(code: int) -> int: + """Collapse a fullMap code to a static-terrain class, or -1 if unknown.""" + # wall stays wall; ore vs empty are the two passable classes. + if code == 2: + return 2 + if code == 1: + return 1 + if code == 0: + return 0 + return -1 # -1 unseen or 3 building -> terrain unknown + + +def _mirror(x: int, y: int, w: int, h: int, sym: int): + if sym == ROTATIONAL: + return w - 1 - x, h - 1 - y + if sym == HORIZONTAL: + return w - 1 - x, y + return x, h - 1 - y # VERTICAL + + +def _core_corner(cx: int, cy: int, w: int, h: int, sym: int): + """Predicted enemy core top-left corner for a 2x2 core mirrored under sym.""" + if sym == ROTATIONAL: + return w - 2 - cx, h - 2 - cy + if sym == HORIZONTAL: + return w - 2 - cx, cy + return cx, h - 2 - cy # VERTICAL + + +class SymmetryTracker: + def __init__(self, w: int, h: int): + self.w = w + self.h = h + self.alive = list(_ALL) # surviving hypotheses + self._ver = -1 # struct_version this was computed at + self._core = None # cached (cx, cy) our core corner + + def update(self, full_map, struct_version: int, core_corner) -> None: + """Recompute survivors when the shared structural map changed. + + Cheap: <=900 tiles x 3 symmetries, only on struct_version change. + """ + if core_corner is not None: + self._core = (core_corner.x, core_corner.y) + if struct_version == self._ver and len(self.alive) <= 1: + return # already resolved, nothing new can help + if struct_version == self._ver: + return + self._ver = struct_version + w, h = self.w, self.h + alive = [] + for sym in self.alive: + ok = True + for x in range(w): + col = full_map[x] + for y in range(h): + t = _terrain(col[y]) + if t < 0: + continue + mx, my = _mirror(x, y, w, h, sym) + mt = _terrain(full_map[mx][my]) + if mt < 0: + continue + if t != mt: + ok = False + break + if not ok: + break + if ok: + alive.append(sym) + # never let noise eliminate everything; keep rotational as a floor + self.alive = alive if alive else [ROTATIONAL] + + def resolved(self) -> bool: + return len(self.alive) == 1 + + def best(self) -> int: + return self.alive[0] if self.alive else ROTATIONAL + + def enemy_core(self, core_corner): + """Best-guess enemy core corner (Position-like via factory in caller).""" + cx, cy = core_corner.x, core_corner.y + return _core_corner(cx, cy, self.w, self.h, self.best()) + + def enemy_core_candidates(self, core_corner): + """All surviving enemy-core corners, most-likely first (as (x,y)).""" + cx, cy = core_corner.x, core_corner.y + seen = set() + out = [] + for sym in self.alive: + c = _core_corner(cx, cy, self.w, self.h, sym) + if c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/conveyor_audit.py b/conveyor_audit.py index 2073af2..cce917d 100644 --- a/conveyor_audit.py +++ b/conveyor_audit.py @@ -116,7 +116,28 @@ def topology(core, entities, team): if output not in footprint and output not in conveyors: dead_ends.add(position) + cycle_components = set() + cycle_conveyors = set() + for start in conveyors: + path = [] + path_index = {} + position = start + while position in conveyors: + if position in path_index: + cycle = frozenset(path[path_index[position]:]) + cycle_components.add(cycle) + cycle_conveyors.update(cycle) + break + path_index[position] = len(path) + path.append(position) + facing = conveyors[position]["facing"] + delta = DIRECTION_DELTA.get(facing) + if delta is None: + break + position = (position[0] + delta[0], position[1] + delta[1]) + served = set() + backfeed_roots = set() for harvester in harvesters: for position, record in conveyors.items(): if abs(position[0] - harvester[0]) + abs(position[1] - harvester[1]) != 1: @@ -125,6 +146,8 @@ def topology(core, entities, team): if delta is None: continue output = (position[0] + delta[0], position[1] + delta[1]) + if output == harvester: + backfeed_roots.add((harvester, position)) if output != harvester and position in connected: served.add(harvester) break @@ -133,6 +156,9 @@ def topology(core, entities, team): "connected": len(connected), "disconnected": len(conveyors) - len(connected), "dead_ends": len(dead_ends), + "cycles": len(cycle_components), + "cycle_conveyors": len(cycle_conveyors), + "backfeed_roots": len(backfeed_roots), "harvesters": len(harvesters), "served": len(served), "orphan_positions": sorted(harvesters - served), diff --git a/replay.replay26 b/replay.replay26 index 2efbd7d3aca21eeb8fb0b103d60789a426cfa0be..a6bd5a897754314a70b45f0310ef7e46c6e51273 100644 GIT binary patch literal 62161 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 literal 170049 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

;Wc@9{K4hD9O8K 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 2" + in source + ) + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_healthy_core_keeps_first_target() + test_damaged_core_advances_to_uncovered_target() + test_exact_v9_scope() + test_no_fingerprints() diff --git a/tests/test_meta_damage_sequential400.py b/tests/test_meta_damage_sequential400.py new file mode 100644 index 0000000..da7b8d8 --- /dev/null +++ b/tests/test_meta_damage_sequential400.py @@ -0,0 +1,69 @@ +"""Scope tests for the 400-HP sequential home responder.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast +import sys + +from fcode import Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_meta_damage_sequential400" +PARENT = ROOT / "bots" / "meta-generalist-v1" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_damage_sequential400", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def threat(x, y, entity_id): + pos = Position(x, y) + return (0, x, y, entity_id, pos) + + +def test_threshold_matches_existing_recall_damage_floor(): + assert BOT.DAMAGE_SEQUENTIAL_HP == BOT.RECALL_CORE_HP == 400 + + +def test_sequential_target_selection(): + threats = [threat(2, 3, 10), threat(4, 3, 11)] + covered = {Position(2, 3)} + assert BOT.choose_home_threat(threats, covered, False) == Position(2, 3) + assert BOT.choose_home_threat(threats, covered, True) == Position(4, 3) + + +def class_methods(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + player = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Player" + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in player.body if isinstance(node, ast.FunctionDef) + } + + +def test_exact_v9_scope(): + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (PARENT / name).read_bytes() + candidate = class_methods(BOT_DIR / "main.py") + control = class_methods(PARENT / "main.py") + assert candidate.keys() == control.keys() + for name in candidate: + if name != "_assign_home_defender": + assert candidate[name] == control[name] + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_threshold_matches_existing_recall_damage_floor() + test_sequential_target_selection() + test_exact_v9_scope() + test_no_fingerprints() diff --git a/tests/test_meta_multi_recall.py b/tests/test_meta_multi_recall.py new file mode 100644 index 0000000..02e98ac --- /dev/null +++ b/tests/test_meta_multi_recall.py @@ -0,0 +1,63 @@ +"""Deterministic scope tests for exact-v9 multi-threat soft recall.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_meta_multi_recall" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_multi_recall", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_original_damage_trigger_is_preserved(): + assert BOT.should_raise_recall(BOT.RECALL_CORE_HP - 1, 1) + assert not BOT.should_raise_recall(BOT.RECALL_CORE_HP, 1) + assert not BOT.should_raise_recall(BOT.RECALL_CORE_HP - 1, 0) + + +def test_multiple_verified_threats_trigger_before_damage(): + assert BOT.should_raise_recall(500, 2) + assert BOT.should_raise_recall(500, 5) + assert not BOT.should_raise_recall(500, 0) + + +def class_methods(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + player = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Player" + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in player.body if isinstance(node, ast.FunctionDef) + } + + +def test_only_core_trigger_changes_from_exact_v9(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + candidate_methods = class_methods(BOT_DIR / "main.py") + parent_methods = class_methods(parent / "main.py") + assert candidate_methods.keys() == parent_methods.keys() + for name in candidate_methods: + if name != "runCore": + assert candidate_methods[name] == parent_methods[name] + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_original_damage_trigger_is_preserved() + test_multiple_verified_threats_trigger_before_damage() + test_only_core_trigger_changes_from_exact_v9() + test_no_fingerprints() diff --git a/tests/test_meta_recall450.py b/tests/test_meta_recall450.py new file mode 100644 index 0000000..19cc646 --- /dev/null +++ b/tests/test_meta_recall450.py @@ -0,0 +1,55 @@ +"""Scope tests for the earlier verified-damage recall threshold.""" + +from pathlib import Path +import ast + + +ROOT = Path(__file__).resolve().parents[1] +CANDIDATE = ROOT / "bots" / "exp_meta_recall450" +PARENT = ROOT / "bots" / "meta-generalist-v1" + + +def constants(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + return { + node.targets[0].id: ast.literal_eval(node.value) + for node in tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Constant) + } + + +def normalized_tree(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if (isinstance(node, ast.Assign) + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "RECALL_CORE_HP"): + node.value = ast.Constant(value=0) + return ast.dump(tree, include_attributes=False) + + +def test_threshold_moves_only_fifty_hp_earlier(): + assert constants(PARENT / "main.py")["RECALL_CORE_HP"] == 400 + assert constants(CANDIDATE / "main.py")["RECALL_CORE_HP"] == 450 + + +def test_exact_v9_scope(): + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (CANDIDATE / name).read_bytes() == (PARENT / name).read_bytes() + assert normalized_tree(CANDIDATE / "main.py") == normalized_tree( + PARENT / "main.py") + + +def test_no_fingerprints(): + source = (CANDIDATE / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_threshold_moves_only_fifty_hp_earlier() + test_exact_v9_scope() + test_no_fingerprints() diff --git a/tests/test_meta_role2_recall.py b/tests/test_meta_role2_recall.py new file mode 100644 index 0000000..19ad39c --- /dev/null +++ b/tests/test_meta_role2_recall.py @@ -0,0 +1,65 @@ +"""Deterministic scope tests for the bounded role-2 early recall.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_meta_role2_recall" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_role2_recall", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_damaged_core_keeps_full_recall(): + assert BOT.choose_recall_mode( + BOT.RECALL_CORE_HP - 1, 1) == BOT.RECALL_ALL_SIEGERS + assert BOT.choose_recall_mode( + BOT.RECALL_CORE_HP - 1, 3) == BOT.RECALL_ALL_SIEGERS + + +def test_undamaged_multi_threat_recalls_only_role_two(): + assert BOT.choose_recall_mode(500, 1) == BOT.RECALL_NONE + assert BOT.choose_recall_mode(500, 2) == BOT.RECALL_SIEGER_2 + assert BOT.choose_recall_mode(500, 5) == BOT.RECALL_SIEGER_2 + assert BOT.choose_recall_mode(399, 0) == BOT.RECALL_NONE + + +def class_methods(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + player = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Player" + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in player.body if isinstance(node, ast.FunctionDef) + } + + +def test_exact_v9_scope(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + candidate = class_methods(BOT_DIR / "main.py") + control = class_methods(parent / "main.py") + assert candidate.keys() == control.keys() + for name in candidate: + if name not in {"runCore", "_recall_is_up"}: + assert candidate[name] == control[name] + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_damaged_core_keeps_full_recall() + test_undamaged_multi_threat_recalls_only_role_two() + test_exact_v9_scope() + test_no_fingerprints() diff --git a/tests/test_meta_safe_counter60.py b/tests/test_meta_safe_counter60.py new file mode 100644 index 0000000..f5fdf02 --- /dev/null +++ b/tests/test_meta_safe_counter60.py @@ -0,0 +1,54 @@ +"""Scope tests for the lower safe home-counter reserve.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_meta_safe_counter60" +PARENT = ROOT / "bots" / "meta-generalist-v1" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_safe_counter60", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_safe_counter_uses_bounded_reserve(): + assert not BOT.home_counter_budget_allows(60, False) + assert BOT.home_counter_budget_allows(61, False) + assert BOT.home_counter_budget_allows(0, True) + + +def class_methods(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + player = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Player" + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in player.body if isinstance(node, ast.FunctionDef) + } + + +def test_exact_v9_class_and_module_scope(): + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (PARENT / name).read_bytes() + assert class_methods(BOT_DIR / "main.py") == class_methods( + PARENT / "main.py") + source = (BOT_DIR / "main.py").read_text(encoding="utf-8") + assert "SAFE_HOME_COUNTER_FLOOR = 60" in source + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_safe_counter_uses_bounded_reserve() + test_exact_v9_class_and_module_scope() + test_no_fingerprints() diff --git a/tests/test_meta_sequential_responder.py b/tests/test_meta_sequential_responder.py new file mode 100644 index 0000000..d411e81 --- /dev/null +++ b/tests/test_meta_sequential_responder.py @@ -0,0 +1,72 @@ +"""Deterministic scope tests for exact-v9 sequential home response.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_meta_sequential_responder" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_sequential_responder", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def threat(x, y, entity_id): + pos = BOT.Position(x, y) + return (0, x, y, entity_id, pos) + + +def test_first_uncovered_threat_is_selected(): + threats = [threat(2, 3, 10), threat(3, 3, 11), threat(4, 3, 12)] + assert BOT.choose_home_threat(threats, set()) == threats[0][4] + assert BOT.choose_home_threat( + threats, {threats[0][4]}) == threats[1][4] + assert BOT.choose_home_threat( + threats, {threats[0][4], threats[1][4]}) == threats[2][4] + + +def test_all_covered_retains_stable_assignment(): + threats = [threat(2, 3, 10), threat(3, 3, 11)] + assert BOT.choose_home_threat( + threats, {item[4] for item in threats}) == threats[0][4] + assert BOT.choose_home_threat([], set()) is None + + +def class_methods(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + player = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Player" + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in player.body if isinstance(node, ast.FunctionDef) + } + + +def test_only_assignment_method_changes_from_exact_v9(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + candidate_methods = class_methods(BOT_DIR / "main.py") + parent_methods = class_methods(parent / "main.py") + assert candidate_methods.keys() == parent_methods.keys() + for name in candidate_methods: + if name != "_assign_home_defender": + assert candidate_methods[name] == parent_methods[name] + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_first_uncovered_threat_is_selected() + test_all_covered_retains_stable_assignment() + test_only_assignment_method_changes_from_exact_v9() + test_no_fingerprints() diff --git a/tests/test_meta_single_countertrade.py b/tests/test_meta_single_countertrade.py new file mode 100644 index 0000000..710e706 --- /dev/null +++ b/tests/test_meta_single_countertrade.py @@ -0,0 +1,118 @@ +"""Deterministic tests for the exact-v9 single-threat countertrade.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_meta_single_countertrade" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_single_countertrade", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class FakeController: + def __init__(self, seat, facing, target): + self.seat = seat + self.facing = facing + self.target = target + + def get_nearby_tiles(self): + return [self.seat] + + def get_tile_env(self, position): + return BOT.Environment.EMPTY + + def get_tile_building_id(self, position): + return None + + def can_fire_from(self, seat, facing, entity_type, target): + return ( + seat == self.seat and facing == self.facing + and entity_type == BOT.EntityType.GUNNER and target == self.target + ) + + def is_in_vision(self, position): + return True + + def get_tile_builder_bot_id(self, position): + return None + + def is_tile_passable(self, position): + return True + + +def fixture(): + seat = BOT.Position(8, 8) + stand = seat.add(BOT.Direction.NORTH) + target = BOT.Position(10, 10) + facing = BOT.Direction.SOUTHEAST + controller = FakeController(seat, facing, target) + player = BOT.Player() + player.mapW = player.mapH = 20 + return player, controller, seat, stand, target, facing + + +def test_one_turret_allows_bounded_countertrade(): + player, controller, seat, stand, target, facing = fixture() + one_turret = [(1, 1, 10, {seat})] + assert player._bounded_home_counter_plan( + controller, stand, target, one_turret + ) == (seat, stand, facing) + + +def test_builder_stand_remains_strictly_safe(): + player, controller, seat, stand, target, _ = fixture() + unsafe_stands = {seat.add(direction) for direction in BOT.CARDINALS} + one_turret = [(1, 1, 10, {seat} | unsafe_stands)] + assert player._bounded_home_counter_plan( + controller, stand, target, one_turret + ) is None + + +def test_zero_threats_produces_no_plan(): + player, controller, _, stand, target, _ = fixture() + assert player._bounded_home_counter_plan( + controller, stand, target, [] + ) is None + + +def class_methods(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + player = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Player" + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in player.body if isinstance(node, ast.FunctionDef) + } + + +def test_only_bounded_plan_changes_from_exact_v9(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + candidate_methods = class_methods(BOT_DIR / "main.py") + parent_methods = class_methods(parent / "main.py") + assert candidate_methods.keys() == parent_methods.keys() + for name in candidate_methods: + if name != "_bounded_home_counter_plan": + assert candidate_methods[name] == parent_methods[name] + + +def test_no_fingerprints(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_one_turret_allows_bounded_countertrade() + test_builder_stand_remains_strictly_safe() + test_zero_threats_produces_no_plan() + test_only_bounded_plan_changes_from_exact_v9() + test_no_fingerprints() diff --git a/tests/test_route_integrity.py b/tests/test_route_integrity.py new file mode 100644 index 0000000..ad676d7 --- /dev/null +++ b/tests/test_route_integrity.py @@ -0,0 +1,90 @@ +"""Replay-derived tests for surgical conveyor merge validation.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import ast + +from fcode import Direction, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_route_integrity" +spec = spec_from_file_location("route_integrity_pathfinding", BOT_DIR / "mapPathfinding.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +CORE = { + Position(0, 13), Position(1, 13), + Position(0, 14), Position(1, 14), +} + + +def test_replay_cycle_is_rejected(): + # Exact shape observed in Oogway v9's Sweden titanium loss. + cycle = { + Position(4, 11): Direction.SOUTH, + Position(4, 12): Direction.EAST, + Position(5, 12): Direction.NORTH, + Position(5, 11): Direction.WEST, + } + visible = set(cycle) | {position.add(facing) for position, facing in cycle.items()} + for position in cycle: + assert not BOT.visible_conveyor_merge_safe(position, cycle, CORE, visible) + + +def test_visible_chain_reaching_core_is_accepted(): + chain = { + Position(4, 13): Direction.WEST, + Position(3, 13): Direction.WEST, + Position(2, 13): Direction.WEST, + } + assert BOT.visible_conveyor_merge_safe( + Position(4, 13), chain, CORE, set(chain) | CORE) + + +def test_visible_dead_end_is_rejected(): + chain = {Position(4, 13): Direction.WEST} + assert not BOT.visible_conveyor_merge_safe( + Position(4, 13), chain, CORE, + {Position(4, 13), Position(3, 13)}, + ) + + +def test_monotone_chain_may_leave_vision_but_detour_may_not(): + monotone = {Position(4, 13): Direction.WEST} + assert BOT.visible_conveyor_merge_safe( + Position(4, 13), monotone, CORE, {Position(4, 13)}) + detour = {Position(4, 13): Direction.NORTH} + assert not BOT.visible_conveyor_merge_safe( + Position(4, 13), detour, CORE, {Position(4, 13)}) + + +def method_ast(path, class_name, method_name): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return ast.dump(child, include_attributes=False) + raise AssertionError(method_name) + + +def test_parent_behavior_outside_merge_validation_is_preserved(): + parent = ROOT / "bots" / "exp_waller_route_light" + for name in ("main.py", "initialSpawning.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + for method in ("routeConveyor", "moveTo", "getNewTiles"): + assert method_ast(BOT_DIR / "mapPathfinding.py", "MapPathfinder", method) == method_ast( + parent / "mapPathfinding.py", "MapPathfinder", method) + source = (BOT_DIR / "mapPathfinding.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_replay_cycle_is_rejected() + test_visible_chain_reaching_core_is_accepted() + test_visible_dead_end_is_rejected() + test_monotone_chain_may_leave_vision_but_detour_may_not() + test_parent_behavior_outside_merge_validation_is_preserved() diff --git a/tests/test_stalemate_route_repair.py b/tests/test_stalemate_route_repair.py new file mode 100644 index 0000000..95d643c --- /dev/null +++ b/tests/test_stalemate_route_repair.py @@ -0,0 +1,300 @@ +"""Deterministic mechanism tests for exp_stalemate_route_repair.""" + +from __future__ import annotations + +import importlib.util +import ast +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_stalemate_route_repair" + + +def load_bot(): + sys.path.insert(0, str(BOT_DIR)) + try: + spec = importlib.util.spec_from_file_location( + "stalemate_route_repair_main", BOT_DIR / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +def footprint(): + return BOT.core_footprint_positions(Position(0, 0)) + + +def test_only_actual_facing_chains_are_connected(): + conveyors = { + Position(2, 0): Direction.WEST, + Position(3, 0): Direction.WEST, + # Spatially touches the valid trunk, but points away from it. + Position(2, 1): Direction.SOUTH, + } + connected = BOT.proven_connected_conveyors(conveyors, footprint()) + assert connected == {Position(2, 0), Position(3, 0)} + + +def test_wrong_facing_harvester_neighbor_is_not_a_root(): + harvester = Position(5, 5) + conveyors = { + Position(4, 5): Direction.EAST, # outputs into the harvester + Position(5, 4): Direction.WEST, # accepts from the harvester + } + assert BOT.harvester_chain_roots(harvester, conveyors) == [ + Position(5, 4) + ] + + +def test_dead_end_is_extended_but_cycle_is_rejected(): + dead_end = { + Position(5, 5): Direction.WEST, + Position(4, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), dead_end, footprint(), set() + ) == ("dead_end", Position(3, 5)) + + cycle = { + Position(5, 5): Direction.EAST, + Position(6, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), cycle, footprint(), set() + ) == ("cycle", Position(5, 5)) + + +class ObservationController: + def __init__(self): + self.entities = {} + + def get_tile_building_id(self, position): + record = self.entities.get(position) + return None if record is None else record[0] + + def get_team(self, entity_id=None): + if entity_id is None: + return 1 + for record in self.entities.values(): + if record[0] == entity_id: + return record[1] + raise KeyError(entity_id) + + def get_entity_type(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[2] + raise KeyError(entity_id) + + def get_direction(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[3] + raise KeyError(entity_id) + + +def test_revisited_tiles_clear_stale_economy_memory(): + pathfinder = BOT.MapPathfinder() + controller = ObservationController() + conveyor = Position(3, 3) + harvester = Position(4, 4) + controller.entities[conveyor] = ( + 10, 1, EntityType.CONVEYOR, Direction.WEST) + controller.entities[harvester] = ( + 11, 1, EntityType.HARVESTER, None) + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert pathfinder.knownTeamConveyors[conveyor] == Direction.WEST + assert harvester in pathfinder.knownTeamHarvesters + + controller.entities.clear() + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert conveyor not in pathfinder.knownTeamConveyors + assert harvester not in pathfinder.knownTeamHarvesters + + +class CandidateController(ObservationController): + def __init__(self): + super().__init__() + self.builders = {} + + def is_in_vision(self, position): + return True + + def is_tile_passable(self, position): + return position not in self.builders + + def get_tile_builder_bot_id(self, position): + return self.builders.get(position) + + def get_team(self, entity_id=None): + if entity_id in self.builders.values(): + return 1 + return super().get_team(entity_id) + + +def test_served_harvester_produces_no_route_work(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(4, 0) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.WEST, + } + controller = CandidateController() + controller.entities = { + harvester: (20, 1, EntityType.HARVESTER, None), + Position(3, 0): (21, 1, EntityType.CONVEYOR, Direction.WEST), + Position(2, 0): (22, 1, EntityType.CONVEYOR, Direction.WEST), + } + assert pathfinder.lateEconomyRouteCandidates(controller) == [] + + +def test_wrong_facing_neighbor_does_not_hide_orphan_harvester(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + wrong = Position(4, 5) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = {wrong: Direction.EAST} + controller = CandidateController() + controller.entities = { + harvester: (30, 1, EntityType.HARVESTER, None), + wrong: (31, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + ends = {candidate[1] for candidate in candidates} + # The wrong-facing link is now a legal surgical rebuild target; it does + # not make the harvester appear served. + assert wrong in ends + assert ends == { + Position(4, 5), Position(5, 4), Position(5, 6), Position(6, 5) + } + + +def test_friendly_builder_on_missing_link_keeps_route_candidate_alive(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + occupied_end = Position(5, 4) + pathfinder.knownTeamHarvesters = {harvester} + controller = CandidateController() + controller.entities = { + harvester: (40, 1, EntityType.HARVESTER, None), + } + controller.builders[occupied_end] = 41 + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert occupied_end in {candidate[1] for candidate in candidates} + + +def test_orphan_cycle_exposes_one_disconnected_link_for_rebuild(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + cycle = { + Position(6, 5): Direction.SOUTH, + Position(6, 6): Direction.WEST, + Position(5, 6): Direction.EAST, + } + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = cycle + controller = CandidateController() + controller.entities = { + harvester: (50, 1, EntityType.HARVESTER, None), + Position(6, 5): (51, 1, EntityType.CONVEYOR, Direction.SOUTH), + Position(6, 6): (52, 1, EntityType.CONVEYOR, Direction.WEST), + Position(5, 6): (53, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert Position(6, 6) in {candidate[1] for candidate in candidates} + + +def test_late_repair_is_bounded_and_below_direct_threat_priority(): + allowed = BOT.late_route_repair_allowed + assert not allowed(BOT.LATE_ROUTE_REPAIR_START - 1, 500, 1) + assert allowed(BOT.LATE_ROUTE_REPAIR_START, + BOT.LATE_ROUTE_RESOURCE_FLOOR, + BOT.LATE_ROUTE_MAX_LINKS) + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, + BOT.LATE_ROUTE_RESOURCE_FLOOR - 1, 1) + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, 500, + BOT.LATE_ROUTE_MAX_LINKS + 1) + assert BOT.LATE_ROUTE_SCORE_CAP < 10 + assert BOT.LATE_ROUTE_FALLBACK_SCORE_CAP < 2 + + +def test_one_nearest_builder_claims_a_late_repair(): + target = Position(5, 5) + candidates = [ + (9, Position(3, 5)), + (7, Position(5, 3)), + (4, Position(9, 9)), + ] + assert BOT.choose_route_repairer(candidates, target) == 7 + assert BOT.choose_route_repairer(candidates, target, {7, 9}) == 4 + assert BOT.choose_route_repairer( + candidates, target, preferred_ids={9}) == 9 + assert BOT.choose_route_repairer([], target) is None + + +def test_experiment_preserves_protocol_and_has_no_fingerprints(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + source = "\n".join( + (BOT_DIR / name).read_text(encoding="utf-8").lower() + for name in ("main.py", "mapPathfinding.py") + ) + for fingerprint in ("string.map", "bridge.map", "duel.map", "ijti"): + assert fingerprint not in source + + +def method_ast(path, class_name, method_name): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return ast.dump(child, include_attributes=False) + raise AssertionError(f"missing {class_name}.{method_name}") + + +def test_parent_opening_conveyor_router_is_unchanged(): + parent = ROOT / "bots" / "meta-generalist-v1" / "mapPathfinding.py" + candidate = BOT_DIR / "mapPathfinding.py" + for method in ( + "conveyorTileCost", "fillConveyorDistTable", "routeConveyor"): + assert method_ast(candidate, "MapPathfinder", method) == method_ast( + parent, "MapPathfinder", method) + + +if __name__ == "__main__": + test_only_actual_facing_chains_are_connected() + test_wrong_facing_harvester_neighbor_is_not_a_root() + test_dead_end_is_extended_but_cycle_is_rejected() + test_revisited_tiles_clear_stale_economy_memory() + test_served_harvester_produces_no_route_work() + test_wrong_facing_neighbor_does_not_hide_orphan_harvester() + test_friendly_builder_on_missing_link_keeps_route_candidate_alive() + test_orphan_cycle_exposes_one_disconnected_link_for_rebuild() + test_late_repair_is_bounded_and_below_direct_threat_priority() + test_one_nearest_builder_claims_a_late_repair() + test_experiment_preserves_protocol_and_has_no_fingerprints() + test_parent_opening_conveyor_router_is_unchanged() + print("stalemate route repair tests passed") diff --git a/tests/test_v18_build_stands.py b/tests/test_v18_build_stands.py new file mode 100644 index 0000000..a61d822 --- /dev/null +++ b/tests/test_v18_build_stands.py @@ -0,0 +1,55 @@ +"""Deterministic checks for production-legal build stands on v18.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_build_stands" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_build_stands", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class StepController: + def __init__(self): + self.position = Position(2, 2) + self.moved = None + + def get_position(self): return self.position + def can_move(self, direction): return True + def move(self, direction): self.moved = direction + + +class AttackController(StepController): + def get_global_resources(self): return 100 + def can_build_gunner(self, position, direction): return False + + +def test_step_off_prefers_behind_gun_and_toward_core_for_ore(): + player = BOT.Player() + ct = StepController() + assert player.stepOffBuildTile(ct, Position(4, 2), preferFarther=True) + assert ct.moved == Direction.WEST + ct.moved = None + assert player.stepOffBuildTile(ct, Position(0, 2), preferFarther=False) + assert ct.moved == Direction.WEST + + +def test_attacker_on_gun_seat_does_not_retreat_home(): + player = BOT.Player() + player.mapPf.enemyCorePos = Position(4, 2) + player.mapPf.teamCore = Position(0, 2) + player.findGunnerSpot = lambda _: (Position(2, 2), Direction.EAST) + ct = AttackController() + player.runAttack(ct) + assert ct.moved == Direction.WEST + + +if __name__ == "__main__": + test_step_off_prefers_behind_gun_and_toward_core_for_ore() + test_attacker_on_gun_seat_does_not_retreat_home() diff --git a/tests/test_v18_core_threat_filter.py b/tests/test_v18_core_threat_filter.py new file mode 100644 index 0000000..cecc2d4 --- /dev/null +++ b/tests/test_v18_core_threat_filter.py @@ -0,0 +1,39 @@ +"""Deterministic checks for v18 defender threat filtering.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_core_threat_filter" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_core_threat_filter", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class ThreatController: + def __init__(self, attackable): + self.attackable = attackable + + def get_entity_type(self, entity_id): return EntityType.GUNNER + def get_position(self, entity_id): return Position(4, 1) + def get_direction(self, entity_id): return Direction.WEST + def get_attackable_tiles_from(self, position, direction, entity_type): + return self.attackable + + +def test_only_current_attack_pattern_counts(): + player = BOT.Player() + player.mapPf.teamCore = Position(1, 1) + assert player.turretThreatensCore( + ThreatController([Position(3, 1), Position(2, 1)]), 10) + assert not player.turretThreatensCore( + ThreatController([Position(4, 2), Position(3, 3)]), 10) + + +if __name__ == "__main__": + test_only_current_attack_pattern_counts() diff --git a/tests/test_v18_harvest_stand.py b/tests/test_v18_harvest_stand.py new file mode 100644 index 0000000..65f066d --- /dev/null +++ b/tests/test_v18_harvest_stand.py @@ -0,0 +1,38 @@ +"""Deterministic check for the production-legal v18 harvest stand.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, Position + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_harvest_stand" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_harvest_stand", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + +class Controller: + def __init__(self): self.moved = None + def is_in_vision(self, position): return True + def get_tile_building_id(self, position): return None + def get_global_resources(self): return 100 + def can_build_harvester(self, position): return False + def get_position(self): return Position(1, 1) + def can_move(self, direction): return True + def move(self, direction): self.moved = direction + +def test_builder_steps_off_ore_toward_core(): + player = BOT.Player() + player.mapW = player.mapH = 3 + player.mapPf.teamCore = Position(0, 1) + player.mapPf.fullMap = [[-1] * 3 for _ in range(3)] + player.mapPf.fullMap[1][1] = 1 + player.mapPf.enemyTurretThreatenedTiles = lambda _: set() + ct = Controller() + assert player.harvestTask(ct, Position(1, 1), 0) + assert ct.moved == Direction.WEST + +if __name__ == "__main__": + test_builder_steps_off_ore_toward_core() diff --git a/tests/test_v18_heal_stand.py b/tests/test_v18_heal_stand.py new file mode 100644 index 0000000..a35b553 --- /dev/null +++ b/tests/test_v18_heal_stand.py @@ -0,0 +1,27 @@ +"""Deterministic checks for the v18 diagonal core-heal correction.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_heal_stand" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_heal_stand", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_only_orthogonal_ring_tiles_are_heal_stands(): + core = Position(1, 1) + assert BOT.isCoreHealStand(Position(0, 1), core) + assert BOT.isCoreHealStand(Position(2, 0), core) + assert not BOT.isCoreHealStand(Position(0, 0), core) + assert not BOT.isCoreHealStand(Position(3, 3), core) + + +if __name__ == "__main__": + test_only_orthogonal_ring_tiles_are_heal_stands() diff --git a/tests/test_v18_line_integrity.py b/tests/test_v18_line_integrity.py new file mode 100644 index 0000000..cb0b1cb --- /dev/null +++ b/tests/test_v18_line_integrity.py @@ -0,0 +1,112 @@ +"""Deterministic checks for obstruction-aware v18 gun lines.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_line_integrity" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_line_integrity", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class CoverageController: + def __init__(self, reaches): + self.reaches = reaches + + def get_team(self, entity_id=None): return 0 + def get_nearby_buildings(self): return [10] + def get_entity_type(self, entity_id): return EntityType.GUNNER + def get_position(self, entity_id): return Position(1, 1) + def get_direction(self, entity_id): return Direction.EAST + def can_fire_from(self, position, direction, entity_type, target): + return self.reaches + + +class BlockedAimController: + def __init__(self): + self.rotated = None + self.friendly_builder = Position(5, 4) + self.enemy_core = Position(4, 1) + + def get_gunner_target(self): return self.friendly_builder + def get_direction(self, entity_id=None): return Direction.EAST + def get_position(self): return Position(4, 4) + def get_team(self, entity_id=None): + if entity_id in (7, None): return 0 + return 1 + def get_tile_building_id(self, position): + return 20 if position == self.enemy_core else None + def get_tile_builder_bot_id(self, position): + return 7 if position == self.friendly_builder else None + def get_entity_type(self, entity_id): return EntityType.CORE + def get_attackable_tiles_from(self, position, direction, entity_type): + if direction == Direction.EAST: + return [self.friendly_builder, Position(6, 4)] + if direction == Direction.NORTH: + return [self.enemy_core] + return [] + def get_global_resources(self): return 100 + def can_rotate(self, direction): return True + def rotate(self, direction): self.rotated = direction + + +class GunnerPathController: + def __init__(self): + self.position = Position(1, 1) + self.blocker = Position(2, 1) + self.destroyed = False + self.moved = None + + def get_position(self): return self.position + def get_nearby_buildings(self): return [] + def get_team(self, entity_id=None): return 0 + def get_tile_building_id(self, position): + return 10 if position == self.blocker and not self.destroyed else None + def get_entity_type(self, entity_id): return EntityType.GUNNER + def can_destroy(self, position): return position == self.blocker + def destroy(self, position): self.destroyed = True + def can_move(self, direction): + return direction == Direction.EAST and self.destroyed + def move(self, direction): self.moved = direction + + +def test_geometric_coverage_is_not_enough(): + player = BOT.Player() + target = Position(3, 1) + assert not player.friendlyTurretCanHit(CoverageController(False), target) + assert player.friendlyTurretCanHit(CoverageController(True), target) + + +def test_friendly_builder_does_not_freeze_gunner(): + ct = BlockedAimController() + player = BOT.Player() + player.coreThreatSpots = lambda _: set() + player.runGunner(ct) + assert ct.rotated == Direction.NORTH + + +def test_pathfinder_clears_selected_friendly_gunner_blocker(): + pathfinder = BOT.MapPathfinder() + pathfinder.mapW = pathfinder.mapH = 3 + pathfinder.fullMap = [[0] * 3 for _ in range(3)] + pathfinder.distMap = [[99] * 3 for _ in range(3)] + pathfinder.distMap[2][1] = 0 + target = Position(2, 2) + pathfinder.prevTarget = target + pathfinder.mapChanged = False + ct = GunnerPathController() + pathfinder.moveTo(ct, target) + assert ct.destroyed + assert ct.moved == Direction.EAST + + +if __name__ == "__main__": + test_geometric_coverage_is_not_enough() + test_friendly_builder_does_not_freeze_gunner() + test_pathfinder_clears_selected_friendly_gunner_blocker() diff --git a/tests/test_v18_siege_stand.py b/tests/test_v18_siege_stand.py new file mode 100644 index 0000000..f5c97cc --- /dev/null +++ b/tests/test_v18_siege_stand.py @@ -0,0 +1,34 @@ +"""Deterministic check for the production-legal v18 siege stand.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, Position + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_siege_stand" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_siege_stand", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + +class Controller: + def __init__(self): self.moved = None + def get_position(self): return Position(2, 2) + def get_global_resources(self): return 100 + def can_build_gunner(self, position, direction): return False + def can_move(self, direction): return True + def move(self, direction): self.moved = direction + +def test_attacker_steps_behind_occupied_planned_seat(): + player = BOT.Player() + player.mapPf.enemyCorePos = Position(4, 2) + player.mapPf.teamCore = Position(0, 2) + player.findGunnerSpot = lambda _: (Position(2, 2), Direction.EAST) + ct = Controller() + player.runAttack(ct) + assert ct.moved == Direction.WEST + +if __name__ == "__main__": + test_attacker_steps_behind_occupied_planned_seat() diff --git a/tests/test_v18_spawn_discipline.py b/tests/test_v18_spawn_discipline.py new file mode 100644 index 0000000..cd887ce --- /dev/null +++ b/tests/test_v18_spawn_discipline.py @@ -0,0 +1,30 @@ +"""Deterministic checks for congestion-aware spawning on the v18 chassis.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_spawn_discipline" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_spawn_discipline", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_opening_four_ignore_congestion(): + assert BOT.extraSpawnAllowed(0, 0, 20) + assert BOT.extraSpawnAllowed(3, 0, 20) + + +def test_extra_spawn_stops_at_six_and_resumes_after_dispersal(): + assert BOT.extraSpawnAllowed(4, 361, 5) + assert not BOT.extraSpawnAllowed(4, 361, 6) + assert BOT.extraSpawnAllowed(4, 361, 4) + assert not BOT.extraSpawnAllowed(4, 360, 0) + + +if __name__ == "__main__": + test_opening_four_ignore_congestion() + test_extra_spawn_stops_at_six_and_resumes_after_dispersal() diff --git a/tests/test_v18_spawn_immediate_seat.py b/tests/test_v18_spawn_immediate_seat.py new file mode 100644 index 0000000..c947857 --- /dev/null +++ b/tests/test_v18_spawn_immediate_seat.py @@ -0,0 +1,58 @@ +"""Deterministic checks for immediate-buildable siege-seat preference.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, Environment, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_spawn_immediate_seat" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_spawn_immediate_seat", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class SeatController: + def __init__(self, blocked_builder=None, buildable=None): + self.blocked_builder = blocked_builder + self.buildable = buildable + + def get_position(self): return Position(2, 2) + def is_in_vision(self, tile): return True + def get_tile_building_id(self, tile): return None + def get_tile_builder_bot_id(self, tile): + return 9 if tile == self.blocked_builder else None + def get_tile_env(self, tile): return Environment.EMPTY + def can_build_gunner(self, tile, direction): return tile == self.buildable + + +def make_player(candidates, coverage=None): + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf.enemyCorePos = Position(7, 7) + player.getAttackableTiles = lambda _: candidates + player.enemyTurretCoverage = lambda _: coverage or {} + return player + + +def test_immediate_legal_seat_beats_remote_uncovered_plan(): + immediate = (Position(2, 1), Direction.SOUTH) + remote = (Position(5, 5), Direction.SOUTHEAST) + player = make_player([remote, immediate], {(2, 1): 1}) + assert player.findGunnerSpot(SeatController(buildable=immediate[0])) == immediate + + +def test_builder_occupied_seat_is_rejected(): + blocked = (Position(2, 1), Direction.SOUTH) + open_seat = (Position(3, 2), Direction.EAST) + player = make_player([blocked, open_seat]) + assert player.findGunnerSpot( + SeatController(blocked_builder=blocked[0], buildable=open_seat[0])) == open_seat + + +if __name__ == "__main__": + test_immediate_legal_seat_beats_remote_uncovered_plan() + test_builder_occupied_seat_is_rejected() diff --git a/tests/test_v18_spawn_passive_heal.py b/tests/test_v18_spawn_passive_heal.py new file mode 100644 index 0000000..4718000 --- /dev/null +++ b/tests/test_v18_spawn_passive_heal.py @@ -0,0 +1,61 @@ +"""Deterministic checks for Pantheon-style spare-action healing.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position, Team + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_spawn_passive_heal" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v18_spawn_passive_heal", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class HealController: + def __init__(self, resources=100, cooldown=0): + self.resources = resources + self.cooldown = cooldown + self.healed = None + origin = Position(5, 5) + self.buildings = { + origin.add(Direction.NORTH): 10, + origin.add(Direction.EAST): 11, + } + self.types = {10: EntityType.CORE, 11: EntityType.GUNNER} + self.hp = {10: 499, 11: 20} + self.max_hp = {10: 500, 11: 40} + + def get_action_cooldown(self): return self.cooldown + def get_global_resources(self): return self.resources + def get_position(self): return Position(5, 5) + def get_team(self, entity_id=None): return Team.A + def get_tile_building_id(self, tile): return self.buildings.get(tile) + def get_hp(self, entity_id): return self.hp[entity_id] + def get_max_hp(self, entity_id): return self.max_hp[entity_id] + def get_entity_type(self, entity_id): return self.types[entity_id] + def can_heal(self, tile): return tile in self.buildings + def heal(self, tile): self.healed = tile + + +def test_core_priority_beats_more_damaged_gunner(): + ct = HealController() + assert BOT.Player().tryPassiveHeal(ct) + assert ct.healed == Position(5, 4) + + +def test_passive_heal_never_spends_reserve_or_overrides_action(): + reserve = HealController(resources=BOT.PASSIVE_HEAL_TITANIUM_FLOOR) + assert not BOT.Player().tryPassiveHeal(reserve) + assert reserve.healed is None + acted = HealController(cooldown=1) + assert not BOT.Player().tryPassiveHeal(acted) + assert acted.healed is None + + +if __name__ == "__main__": + test_core_priority_beats_more_damaged_gunner() + test_passive_heal_never_spends_reserve_or_overrides_action() diff --git a/tests/test_v18_spawn_route_proof.py b/tests/test_v18_spawn_route_proof.py new file mode 100644 index 0000000..1c6d9a7 --- /dev/null +++ b/tests/test_v18_spawn_route_proof.py @@ -0,0 +1,57 @@ +"""Deterministic checks for facing-proven conveyor merge targets.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from fcode import Direction, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_spawn_route_proof" +spec = spec_from_file_location( + "v18_spawn_route_proof_map", BOT_DIR / "mapPathfinding.py") +MAP = module_from_spec(spec) +spec.loader.exec_module(MAP) + + +CORE = Position(0, 0) +CORE_TILES = MAP.coreFootprint(CORE) + + +def test_chain_reaching_core_is_safe_merge(): + directions = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.WEST, + } + visible = {Position(x, 0) for x in range(5)} + assert MAP.visibleConveyorMergeSafe( + Position(3, 0), directions, CORE_TILES, visible) + + +def test_visible_dead_end_and_cycle_are_rejected(): + dead_end = {Position(3, 0): Direction.WEST} + visible = {Position(x, 0) for x in range(5)} + assert not MAP.visibleConveyorMergeSafe( + Position(3, 0), dead_end, CORE_TILES, visible) + cycle = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.EAST, + } + assert not MAP.visibleConveyorMergeSafe( + Position(3, 0), cycle, CORE_TILES, visible) + + +def test_unseen_continuation_requires_strict_core_progress(): + visible = {Position(3, 0)} + assert MAP.visibleConveyorMergeSafe( + Position(3, 0), {Position(3, 0): Direction.WEST}, + CORE_TILES, visible) + assert not MAP.visibleConveyorMergeSafe( + Position(3, 0), {Position(3, 0): Direction.EAST}, + CORE_TILES, visible) + + +if __name__ == "__main__": + test_chain_reaching_core_is_safe_merge() + test_visible_dead_end_and_cycle_are_rejected() + test_unseen_continuation_requires_strict_core_progress() diff --git a/tests/test_v18_spawn_route_proof_guard.py b/tests/test_v18_spawn_route_proof_guard.py new file mode 100644 index 0000000..8e0bb77 --- /dev/null +++ b/tests/test_v18_spawn_route_proof_guard.py @@ -0,0 +1,43 @@ +"""Regression check for defender home planning outside current vision.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_spawn_route_proof_guard" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location( + "v18_spawn_route_proof_guard", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class MapStub: + myNum = 4 + teamCore = Position(2, 2) + + @staticmethod + def getTileEnv(tile): return 0 + + +class OutOfVisionController: + def is_in_vision(self, tile): return False + def get_tile_building_id(self, tile): + raise AssertionError("out-of-vision occupant query") + def get_nearby_buildings(self): return [] + def get_team(self, entity_id=None): return 0 + + +def test_distant_defender_plans_home_without_live_tile_query(): + player = BOT.Player() + player.mapPf = MapStub() + player.mapW = player.mapH = 10 + assert player.getDefendHome(OutOfVisionController()) is not None + + +if __name__ == "__main__": + test_distant_defender_plans_home_without_live_tile_query() diff --git a/tests/test_v18_spawn_route_proof_vision_skip.py b/tests/test_v18_spawn_route_proof_vision_skip.py new file mode 100644 index 0000000..18a16cf --- /dev/null +++ b/tests/test_v18_spawn_route_proof_vision_skip.py @@ -0,0 +1,44 @@ +"""Regression check for vision-safe defender return planning.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v18_spawn_route_proof_vision_skip" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location( + "v18_spawn_route_proof_vision_skip", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class MapStub: + myNum = 4 + teamCore = Position(2, 2) + + @staticmethod + def getTileEnv(tile): return 0 + + +class OutOfVisionController: + def is_in_vision(self, tile): return False + def get_tile_building_id(self, tile): + raise AssertionError("out-of-vision occupant query") + def get_nearby_buildings(self): return [] + def get_team(self, entity_id=None): return 0 + + +def test_distant_defender_approaches_core_without_caching_ring(): + player = BOT.Player() + player.mapPf = MapStub() + player.mapW = player.mapH = 10 + assert player.getDefendHome(OutOfVisionController()) == Position(2, 2) + assert player.defendHome is None + + +if __name__ == "__main__": + test_distant_defender_approaches_core_without_caching_ring() diff --git a/tests/test_v19_near_core_finish.py b/tests/test_v19_near_core_finish.py new file mode 100644 index 0000000..1ba7a46 --- /dev/null +++ b/tests/test_v19_near_core_finish.py @@ -0,0 +1,72 @@ +"""Checks that only cheap near-core route completions become persistent.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v19_near_core_finish" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v19_near_core_finish", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class MapStub: + teamCore = Position(7, 2) + def __init__(self): self.routed = None + def routeConveyor(self, ct, target): self.routed = target + + +class MovingMapStub(MapStub): + def routeConveyor(self, ct, target): + self.routed = target + ct.visible = False + + +class RoundController: + def get_current_round(self): return 10 + + +class MovingController(RoundController): + def __init__(self): self.visible = True + def get_team(self, entity_id=None): return 0 + def get_position(self): return Position(4, 2) + def is_in_vision(self, target): return self.visible + def get_tile_building_id(self, target): + if not self.visible: + raise AssertionError("post-move out-of-vision query") + return None + + +def test_core_footprint_distance_handles_two_by_two_core(): + assert BOT.coreFootprintManhattan(Position(5, 2), Position(7, 2)) == 2 + assert BOT.coreFootprintManhattan(Position(8, 3), Position(7, 2)) == 0 + + +def test_far_route_is_not_committed(): + player = BOT.Player() + player.mapPf = MapStub() + target = Position(0, 0) + assert player.commitRoute(RoundController(), target) + assert player.mapPf.routed == target + assert player.routeTarget is None + + +def test_post_route_move_rechecks_vision_before_tile_query(): + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MovingMapStub() + player.routeTarget = Position(5, 2) + player.routeLastProgressRound = 10 + assert player.resumeCommittedRoute(MovingController()) + assert player.routeTarget == Position(5, 2) + + +if __name__ == "__main__": + test_core_footprint_distance_handles_two_by_two_core() + test_far_route_is_not_committed() + test_post_route_move_rechecks_vision_before_tile_query() diff --git a/tests/test_v19_sticky_route.py b/tests/test_v19_sticky_route.py new file mode 100644 index 0000000..6de8aac --- /dev/null +++ b/tests/test_v19_sticky_route.py @@ -0,0 +1,69 @@ +"""Deterministic checks for interrupted-route commitment and expiry.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position, Team + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v19_sticky_route" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v19_sticky_route", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class MapStub: + teamCore = Position(7, 2) + + def __init__(self): self.routed = None + def routeConveyor(self, ct, target): self.routed = target + def moveTo(self, ct, target): self.routed = target + + +class RouteController: + def __init__(self, round_number=10): + self.round_number = round_number + self.buildings = {Position(2, 2): 20, Position(3, 2): 21} + + def get_team(self, entity_id=None): return Team.A + def get_current_round(self): return self.round_number + def is_in_vision(self, target): return True + def get_tile_building_id(self, target): return self.buildings.get(target) + def get_entity_type(self, entity_id): return EntityType.CONVEYOR + def get_direction(self, entity_id): return Direction.EAST + def get_position(self): return Position(1, 2) + + +def make_player(): + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub() + return player + + +def test_commitment_skips_built_suffix_and_targets_first_gap(): + player = make_player() + player.routeTarget = Position(2, 2) + ct = RouteController() + assert player.resumeCommittedRoute(ct) + assert player.mapPf.routed == Position(4, 2) + assert player.routeTarget == Position(4, 2) + + +def test_commitment_expires_after_bounded_no_progress(): + player = make_player() + player.routeTarget = Position(5, 5) + player.routeBestDistance = 1 + player.routeLastProgressRound = 1 + ct = RouteController(round_number=1 + BOT.ROUTE_STALL_ROUNDS) + assert not player.resumeCommittedRoute(ct) + assert player.routeTarget is None + assert player.mapPf.routed is None + + +if __name__ == "__main__": + test_commitment_skips_built_suffix_and_targets_first_gap() + test_commitment_expires_after_bounded_no_progress() diff --git a/tests/test_v20_gunner_preempt.py b/tests/test_v20_gunner_preempt.py new file mode 100644 index 0000000..9549dc0 --- /dev/null +++ b/tests/test_v20_gunner_preempt.py @@ -0,0 +1,87 @@ +"""Gunners must choose the best facing before firing the current line.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v20_gunner_preempt" +PARENT = ROOT / "bots" / "exp_v19_near_core_finish" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v20_gunner_preempt", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class AimController: + def __init__(self, current_type, alternate_type, resources=100): + self.current = Position(7, 4) + self.alternate = Position(4, 1) + self.types = {10: current_type, 11: alternate_type} + self.positions = {self.current: 10, self.alternate: 11} + self.resources = resources + self.rotated = None + self.fired = None + + def get_gunner_target(self): return self.current + def get_direction(self, entity_id=None): + return Direction.EAST if entity_id is None else Direction.SOUTH + def get_position(self): return Position(4, 4) + def get_team(self, entity_id=None): return 0 if entity_id is None else 1 + def get_tile_building_id(self, position): return self.positions.get(position) + def get_tile_builder_bot_id(self, position): return None + def get_entity_type(self, entity_id): return self.types[entity_id] + def get_attackable_tiles_from(self, position, direction, entity_type): + if direction == Direction.EAST: return [self.current] + if direction == Direction.NORTH: return [self.alternate] + return [] + def get_global_resources(self): return self.resources + def can_rotate(self, direction): return True + def rotate(self, direction): self.rotated = direction + def can_fire(self, target): return target == self.current + def fire(self, target): self.fired = target + + +def run(controller): + player = BOT.Player() + player.coreThreatSpots = lambda _: set() + player.runGunner(controller) + + +def test_enemy_core_facing_preempts_current_turret_shot(): + ct = AimController(EntityType.GUNNER, EntityType.CORE) + run(ct) + assert ct.rotated == Direction.NORTH + assert ct.fired is None + + +def test_current_core_target_still_fires_immediately(): + ct = AimController(EntityType.CORE, EntityType.GUNNER) + run(ct) + assert ct.rotated is None + assert ct.fired == ct.current + + +def test_unaffordable_rotation_keeps_useful_current_shot(): + ct = AimController(EntityType.GUNNER, EntityType.CORE, resources=50) + run(ct) + assert ct.rotated is None + assert ct.fired == ct.current + + +def test_scope_is_main_only_and_has_no_fingerprints(): + assert (BOT_DIR / "mapPathfinding.py").read_bytes() == ( + PARENT / "mapPathfinding.py").read_bytes() + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("sweden.map", "runestone.map", "team lazy", "smartfridge"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_enemy_core_facing_preempts_current_turret_shot() + test_current_core_target_still_fires_immediately() + test_unaffordable_rotation_keeps_useful_current_shot() + test_scope_is_main_only_and_has_no_fingerprints() diff --git a/tests/test_v20_opportunistic_pressure.py b/tests/test_v20_opportunistic_pressure.py new file mode 100644 index 0000000..2d4758c --- /dev/null +++ b/tests/test_v20_opportunistic_pressure.py @@ -0,0 +1,29 @@ +"""Deterministic ordering checks for attack-first immediate pressure.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v20_opportunistic_pressure" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location( + "v20_opportunistic_pressure", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_safer_immediate_pressure_plan_wins_deterministically(): + safe = BOT.immediatePressureScore(0, Position(4, 2), Direction.EAST) + covered = BOT.immediatePressureScore(1, Position(1, 1), Direction.NORTH) + assert safe < covered + assert BOT.immediatePressureScore( + 0, Position(2, 3), Direction.SOUTH) < BOT.immediatePressureScore( + 0, Position(3, 2), Direction.NORTH) + + +if __name__ == "__main__": + test_safer_immediate_pressure_plan_wins_deterministically() diff --git a/tests/test_v20_opportunistic_trunk_repair.py b/tests/test_v20_opportunistic_trunk_repair.py new file mode 100644 index 0000000..7fe190b --- /dev/null +++ b/tests/test_v20_opportunistic_trunk_repair.py @@ -0,0 +1,111 @@ +"""Deterministic checks for bounded repair of formerly productive edges.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v20_opportunistic_trunk_repair" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v20_opportunistic_trunk_repair", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def key(pos): + return pos.x * 32 + pos.y + + +class MapStub: + teamCore = Position(5, 5) + + def __init__(self, conv_dirs=None, conv_safe=None): + self.conv_dirs = conv_dirs or {} + self.conv_safe = conv_safe or {} + self.moved = None + + def classifyConveyors(self, ct, team): + return self.conv_dirs, {}, self.conv_safe + + def moveTo(self, ct, target): + self.moved = target + + +class ControllerStub: + def __init__(self): + self.store = [0] * 16 + self.built = None + self.round = 100 + self.entities = {} + + def get_current_round(self): return self.round + def get_id(self): return 17 + def get_team(self, entity_id=None): return 0 + def is_in_vision(self, pos): return 0 <= pos.x < 10 and 0 <= pos.y < 10 + def get_tile_building_id(self, pos): return self.entities.get(pos) + def get_entity_type(self, entity_id): return EntityType.CONVEYOR + def get_direction(self, entity_id): return Direction.EAST + def read_store(self, slot): return self.store[slot] + def write_store(self, slot, value): self.store[slot] = value + def can_build_conveyor(self, pos, direction): return pos == Position(4, 5) + def build_conveyor(self, pos, direction): self.built = (pos, direction) + + +def live_upstream(): + upstream = Position(3, 5) + return {key(upstream): [upstream, Direction.EAST, True]} + + +def test_missing_edge_requires_live_upstream_and_proven_downstream(): + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub() + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + assert player.repairCandidate(ct, Position(2, 5), 0, live_upstream(), {}) == ( + Position(4, 5), Direction.EAST) + assert player.repairCandidate(ct, Position(2, 5), 0, {}, {}) is None + + # A remembered branch whose output is not currently proven never becomes + # a repair target merely because it used to exist. + player.knownConnectedConveyors[key(Position(4, 3))] = ( + Position(4, 3), Direction.EAST) + branch_upstream = Position(3, 3) + branch_dirs = {key(branch_upstream): [branch_upstream, Direction.EAST, False]} + candidate = player.repairCandidate(ct, Position(4, 2), 0, branch_dirs, {}) + assert candidate is None + + +def test_repairs_exact_remembered_facing(): + conv_dirs = live_upstream() + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub(conv_dirs, {}) + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + assert player.repairFormerlyConnectedTrunk(ct, Position(3, 4), 0) + assert ct.built == (Position(4, 5), Direction.EAST) + + +def test_never_walks_or_claims_when_edge_is_not_immediately_buildable(): + conv_dirs = live_upstream() + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub(conv_dirs, {}) + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + ct.can_build_conveyor = lambda pos, direction: False + assert not player.repairFormerlyConnectedTrunk(ct, Position(1, 1), 0) + assert player.mapPf.moved is None + + +if __name__ == "__main__": + test_missing_edge_requires_live_upstream_and_proven_downstream() + test_repairs_exact_remembered_facing() + test_never_walks_or_claims_when_edge_is_not_immediately_buildable() diff --git a/tests/test_v20_trunk_repair.py b/tests/test_v20_trunk_repair.py new file mode 100644 index 0000000..09e3111 --- /dev/null +++ b/tests/test_v20_trunk_repair.py @@ -0,0 +1,111 @@ +"""Deterministic checks for bounded repair of formerly productive edges.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v20_trunk_repair" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v20_trunk_repair", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def key(pos): + return pos.x * 32 + pos.y + + +class MapStub: + teamCore = Position(5, 5) + + def __init__(self, conv_dirs=None, conv_safe=None): + self.conv_dirs = conv_dirs or {} + self.conv_safe = conv_safe or {} + self.moved = None + + def classifyConveyors(self, ct, team): + return self.conv_dirs, {}, self.conv_safe + + def moveTo(self, ct, target): + self.moved = target + + +class ControllerStub: + def __init__(self): + self.store = [0] * 16 + self.built = None + self.round = 100 + self.entities = {} + + def get_current_round(self): return self.round + def get_id(self): return 17 + def get_team(self, entity_id=None): return 0 + def is_in_vision(self, pos): return 0 <= pos.x < 10 and 0 <= pos.y < 10 + def get_tile_building_id(self, pos): return self.entities.get(pos) + def get_entity_type(self, entity_id): return EntityType.CONVEYOR + def get_direction(self, entity_id): return Direction.EAST + def read_store(self, slot): return self.store[slot] + def write_store(self, slot, value): self.store[slot] = value + def can_build_conveyor(self, pos, direction): return pos == Position(4, 5) + def build_conveyor(self, pos, direction): self.built = (pos, direction) + + +def live_upstream(): + upstream = Position(3, 5) + return {key(upstream): [upstream, Direction.EAST, True]} + + +def test_claim_encoding_and_ttl(): + target = Position(4, 5) + packed = BOT.packRepairTarget(target, Direction.EAST) + assert BOT.unpackRepairTarget(packed) == (target, Direction.EAST) + owner = BOT.packRepairOwnerRound(17, 100) + assert BOT.unpackRepairOwnerRound(owner) == (17, 100) + assert BOT.repairClaimActive(owner, 106) + assert not BOT.repairClaimActive(owner, 107) + + +def test_missing_edge_requires_live_upstream_and_proven_downstream(): + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub() + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + assert player.repairCandidate(ct, Position(2, 5), 0, live_upstream(), {}) == ( + Position(4, 5), Direction.EAST) + assert player.repairCandidate(ct, Position(2, 5), 0, {}, {}) is None + + # A remembered branch whose output is not currently proven never becomes + # a repair target merely because it used to exist. + player.knownConnectedConveyors[key(Position(4, 3))] = ( + Position(4, 3), Direction.EAST) + branch_upstream = Position(3, 3) + branch_dirs = {key(branch_upstream): [branch_upstream, Direction.EAST, False]} + candidate = player.repairCandidate(ct, Position(4, 2), 0, branch_dirs, {}) + assert candidate is None + + +def test_repairs_exact_remembered_facing_and_clears_claim(): + conv_dirs = live_upstream() + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub(conv_dirs, {}) + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + assert player.repairFormerlyConnectedTrunk(ct, Position(3, 4), 0) + assert ct.built == (Position(4, 5), Direction.EAST) + assert ct.store[BOT.REPAIR_TARGET_SLOT] == 0 + assert ct.store[BOT.REPAIR_OWNER_ROUND_SLOT] == 0 + assert player.repairTarget is None + + +if __name__ == "__main__": + test_claim_encoding_and_ttl() + test_missing_edge_requires_live_upstream_and_proven_downstream() + test_repairs_exact_remembered_facing_and_clears_claim() diff --git a/tests/test_v20_trunk_repair_late.py b/tests/test_v20_trunk_repair_late.py new file mode 100644 index 0000000..26b2417 --- /dev/null +++ b/tests/test_v20_trunk_repair_late.py @@ -0,0 +1,126 @@ +"""Deterministic checks for bounded repair of formerly productive edges.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v20_trunk_repair_late" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v20_trunk_repair_late", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def key(pos): + return pos.x * 32 + pos.y + + +class MapStub: + teamCore = Position(5, 5) + + def __init__(self, conv_dirs=None, conv_safe=None): + self.conv_dirs = conv_dirs or {} + self.conv_safe = conv_safe or {} + self.moved = None + + def classifyConveyors(self, ct, team): + return self.conv_dirs, {}, self.conv_safe + + def moveTo(self, ct, target): + self.moved = target + + +class ControllerStub: + def __init__(self): + self.store = [0] * 16 + self.built = None + self.round = 100 + self.entities = {} + + def get_current_round(self): return self.round + def get_id(self): return 17 + def get_team(self, entity_id=None): return 0 + def is_in_vision(self, pos): return 0 <= pos.x < 10 and 0 <= pos.y < 10 + def get_tile_building_id(self, pos): return self.entities.get(pos) + def get_entity_type(self, entity_id): return EntityType.CONVEYOR + def get_direction(self, entity_id): return Direction.EAST + def read_store(self, slot): return self.store[slot] + def write_store(self, slot, value): self.store[slot] = value + def can_build_conveyor(self, pos, direction): return pos == Position(4, 5) + def build_conveyor(self, pos, direction): self.built = (pos, direction) + + +def live_upstream(): + upstream = Position(3, 5) + return {key(upstream): [upstream, Direction.EAST, True]} + + +def test_claim_encoding_and_ttl(): + target = Position(4, 5) + packed = BOT.packRepairTarget(target, Direction.EAST) + assert BOT.unpackRepairTarget(packed) == (target, Direction.EAST) + owner = BOT.packRepairOwnerRound(17, 100) + assert BOT.unpackRepairOwnerRound(owner) == (17, 100) + assert BOT.repairClaimActive(owner, 106) + assert not BOT.repairClaimActive(owner, 107) + + +def test_missing_edge_requires_live_upstream_and_proven_downstream(): + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub() + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + assert player.repairCandidate(ct, Position(2, 5), 0, live_upstream(), {}) == ( + Position(4, 5), Direction.EAST) + assert player.repairCandidate(ct, Position(2, 5), 0, {}, {}) is None + + # A remembered branch whose output is not currently proven never becomes + # a repair target merely because it used to exist. + player.knownConnectedConveyors[key(Position(4, 3))] = ( + Position(4, 3), Direction.EAST) + branch_upstream = Position(3, 3) + branch_dirs = {key(branch_upstream): [branch_upstream, Direction.EAST, False]} + candidate = player.repairCandidate(ct, Position(4, 2), 0, branch_dirs, {}) + assert candidate is None + + +def test_repairs_exact_remembered_facing_and_clears_claim(): + conv_dirs = live_upstream() + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub(conv_dirs, {}) + player.knownConnectedConveyors[key(Position(4, 5))] = ( + Position(4, 5), Direction.EAST) + ct = ControllerStub() + ct.round = BOT.REPAIR_MIN_ROUND + assert player.repairFormerlyConnectedTrunk(ct, Position(3, 4), 0) + assert ct.built == (Position(4, 5), Direction.EAST) + assert ct.store[BOT.REPAIR_TARGET_SLOT] == 0 + assert ct.store[BOT.REPAIR_OWNER_ROUND_SLOT] == 0 + assert player.repairTarget is None + + +def test_observes_but_does_not_repair_during_opening(): + downstream = Position(4, 5) + conv_dirs = live_upstream() + conv_dirs[key(downstream)] = [downstream, Direction.EAST, False] + player = BOT.Player() + player.mapW = player.mapH = 10 + player.mapPf = MapStub(conv_dirs, {key(downstream): True}) + ct = ControllerStub() + assert not player.repairFormerlyConnectedTrunk(ct, Position(3, 4), 0) + assert key(downstream) in player.knownConnectedConveyors + assert ct.built is None + + +if __name__ == "__main__": + test_claim_encoding_and_ttl() + test_missing_edge_requires_live_upstream_and_proven_downstream() + test_repairs_exact_remembered_facing_and_clears_claim() + test_observes_but_does_not_repair_during_opening() diff --git a/tests/test_v21_eco_release.py b/tests/test_v21_eco_release.py new file mode 100644 index 0000000..2fb60e0 --- /dev/null +++ b/tests/test_v21_eco_release.py @@ -0,0 +1,77 @@ +"""Covered home threats must not permanently recall economy builders.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v21_eco_release" +PARENT = ROOT / "bots" / "exp_v20_opportunistic_trunk_repair" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v21_eco_release", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class MapStub: + teamCore = Position(2, 2) + + +class ControllerStub: + enemy = Position(5, 5) + + def get_team(self, entity_id=None): return 0 if entity_id is None else 1 + def get_nearby_buildings(self): return [9] + def get_entity_type(self, entity_id): return EntityType.GUNNER + def get_position(self, entity_id=None): + return Position(3, 3) if entity_id is None else self.enemy + + +def player_with_stubs(covered): + player = BOT.Player() + player.mapPf = MapStub() + player.coveredTiles = lambda _: covered + player.healCore = lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("covered gun recalled economy builder")) + player.buildGunnerFor = lambda *args: (_ for _ in ()).throw( + AssertionError("covered gun built another counter")) + player.repairFormerlyConnectedTrunk = lambda *args: True + player.resumeCommittedRoute = lambda *args: False + player.routeConveyorTask = lambda *args: False + player.routeHarvesterTask = lambda *args: False + player.harvestTask = lambda *args: False + return player + + +def test_covered_enemy_releases_economy_instead_of_healing(): + ct = ControllerStub() + player = player_with_stubs({(ct.enemy.x, ct.enemy.y)}) + player.runEco(ct) + + +def test_uncovered_enemy_still_has_counter_priority(): + ct = ControllerStub() + player = player_with_stubs(set()) + built = [] + player.buildGunnerFor = lambda controller, target: built.append(target) or True + player.repairFormerlyConnectedTrunk = lambda *args: (_ for _ in ()).throw( + AssertionError("economy ran before uncovered threat was handled")) + player.runEco(ct) + assert built == [ct.enemy] + + +def test_scope_is_one_role_ownership_change(): + assert (BOT_DIR / "mapPathfinding.py").read_bytes() == ( + PARENT / "mapPathfinding.py").read_bytes() + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("string.map", "pantheon", "team lazy", "sweden.map"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_covered_enemy_releases_economy_instead_of_healing() + test_uncovered_enemy_still_has_counter_priority() + test_scope_is_one_role_ownership_change() diff --git a/tests/test_v21_eco_release_only_damaged.py b/tests/test_v21_eco_release_only_damaged.py new file mode 100644 index 0000000..a25c33b --- /dev/null +++ b/tests/test_v21_eco_release_only_damaged.py @@ -0,0 +1,84 @@ +"""Covered threats recall economy only for a visible damaged friendly core.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +from fcode import EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_v21_eco_release_only_damaged" +PARENT = ROOT / "bots" / "exp_v20_opportunistic_trunk_repair" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("v21_eco_release_only_damaged", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class MapStub: + teamCore = Position(2, 2) + + +class ControllerStub: + enemy = Position(5, 5) + + def __init__(self, core_hp=500): self.core_hp = core_hp + def get_position(self, entity_id=None): + return Position(3, 3) if entity_id is None else self.enemy + def get_team(self, entity_id=None): + return 0 if entity_id is None or entity_id == 1 else 1 + def get_nearby_buildings(self): return [1, 9] + def get_entity_type(self, entity_id): + return EntityType.CORE if entity_id == 1 else EntityType.GUNNER + def get_hp(self, entity_id): return self.core_hp + def get_max_hp(self, entity_id): return 500 + + +def configured_player(ct): + player = BOT.Player() + player.mapPf = MapStub() + player.coveredTiles = lambda _: {(ct.enemy.x, ct.enemy.y)} + player.buildGunnerFor = lambda *args: (_ for _ in ()).throw( + AssertionError("covered gun built another counter")) + player.resumeCommittedRoute = lambda *args: False + player.routeConveyorTask = lambda *args: False + player.routeHarvesterTask = lambda *args: False + player.harvestTask = lambda *args: False + return player + + +def test_full_core_releases_economy(): + ct = ControllerStub(core_hp=500) + player = configured_player(ct) + player.healCore = lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("full core recalled economy builder")) + repaired = [] + player.repairFormerlyConnectedTrunk = lambda *args: repaired.append(True) or True + player.runEco(ct) + assert repaired == [True] + + +def test_damaged_visible_core_keeps_healing_priority(): + ct = ControllerStub(core_hp=480) + player = configured_player(ct) + healed = [] + player.healCore = lambda *args, **kwargs: healed.append(True) + player.repairFormerlyConnectedTrunk = lambda *args: (_ for _ in ()).throw( + AssertionError("economy ran before damaged-core healing")) + player.runEco(ct) + assert healed == [True] + + +def test_scope_and_no_fingerprints(): + assert (BOT_DIR / "mapPathfinding.py").read_bytes() == ( + PARENT / "mapPathfinding.py").read_bytes() + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + for fingerprint in ("string.map", "pantheon", "team lazy", "sweden.map"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_full_core_releases_economy() + test_damaged_visible_core_keeps_healing_priority() + test_scope_and_no_fingerprints() diff --git a/tests/test_waller_route_light.py b/tests/test_waller_route_light.py new file mode 100644 index 0000000..c07e71b --- /dev/null +++ b/tests/test_waller_route_light.py @@ -0,0 +1,319 @@ +"""Deterministic mechanism tests for exp_waller_route_light.""" + +from __future__ import annotations + +import importlib.util +import ast +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_waller_route_light" + + +def load_bot(): + sys.path.insert(0, str(BOT_DIR)) + try: + spec = importlib.util.spec_from_file_location( + "waller_route_light_main", BOT_DIR / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +def footprint(): + return BOT.core_footprint_positions(Position(0, 0)) + + +def test_only_actual_facing_chains_are_connected(): + conveyors = { + Position(2, 0): Direction.WEST, + Position(3, 0): Direction.WEST, + # Spatially touches the valid trunk, but points away from it. + Position(2, 1): Direction.SOUTH, + } + connected = BOT.proven_connected_conveyors(conveyors, footprint()) + assert connected == {Position(2, 0), Position(3, 0)} + + +def test_wrong_facing_harvester_neighbor_is_not_a_root(): + harvester = Position(5, 5) + conveyors = { + Position(4, 5): Direction.EAST, # outputs into the harvester + Position(5, 4): Direction.WEST, # accepts from the harvester + } + assert BOT.harvester_chain_roots(harvester, conveyors) == [ + Position(5, 4) + ] + + +def test_dead_end_is_extended_but_cycle_is_rejected(): + dead_end = { + Position(5, 5): Direction.WEST, + Position(4, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), dead_end, footprint(), set() + ) == ("dead_end", Position(3, 5)) + + cycle = { + Position(5, 5): Direction.EAST, + Position(6, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), cycle, footprint(), set() + ) == ("cycle", Position(5, 5)) + + +class ObservationController: + def __init__(self): + self.entities = {} + + def get_tile_building_id(self, position): + record = self.entities.get(position) + return None if record is None else record[0] + + def get_team(self, entity_id=None): + if entity_id is None: + return 1 + for record in self.entities.values(): + if record[0] == entity_id: + return record[1] + raise KeyError(entity_id) + + def get_entity_type(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[2] + raise KeyError(entity_id) + + def get_direction(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[3] + raise KeyError(entity_id) + + +def test_revisited_tiles_clear_stale_economy_memory(): + pathfinder = BOT.MapPathfinder() + controller = ObservationController() + conveyor = Position(3, 3) + harvester = Position(4, 4) + controller.entities[conveyor] = ( + 10, 1, EntityType.CONVEYOR, Direction.WEST) + controller.entities[harvester] = ( + 11, 1, EntityType.HARVESTER, None) + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert pathfinder.knownTeamConveyors[conveyor] == Direction.WEST + assert harvester in pathfinder.knownTeamHarvesters + + controller.entities.clear() + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert conveyor not in pathfinder.knownTeamConveyors + assert harvester not in pathfinder.knownTeamHarvesters + + +class CandidateController(ObservationController): + def __init__(self): + super().__init__() + self.builders = {} + + def is_in_vision(self, position): + return True + + def is_tile_passable(self, position): + return position not in self.builders + + def get_tile_builder_bot_id(self, position): + return self.builders.get(position) + + def get_team(self, entity_id=None): + if entity_id in self.builders.values(): + return 1 + return super().get_team(entity_id) + + +def test_served_harvester_produces_no_route_work(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(4, 0) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.WEST, + } + controller = CandidateController() + controller.entities = { + harvester: (20, 1, EntityType.HARVESTER, None), + Position(3, 0): (21, 1, EntityType.CONVEYOR, Direction.WEST), + Position(2, 0): (22, 1, EntityType.CONVEYOR, Direction.WEST), + } + assert pathfinder.lateEconomyRouteCandidates(controller) == [] + + +def test_wrong_facing_neighbor_does_not_hide_orphan_harvester(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + wrong = Position(4, 5) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = {wrong: Direction.EAST} + controller = CandidateController() + controller.entities = { + harvester: (30, 1, EntityType.HARVESTER, None), + wrong: (31, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + ends = {candidate[1] for candidate in candidates} + # The wrong-facing link is now a legal surgical rebuild target; it does + # not make the harvester appear served. + assert wrong in ends + assert ends == { + Position(4, 5), Position(5, 4), Position(5, 6), Position(6, 5) + } + + +def test_friendly_builder_on_missing_link_keeps_route_candidate_alive(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + occupied_end = Position(5, 4) + pathfinder.knownTeamHarvesters = {harvester} + controller = CandidateController() + controller.entities = { + harvester: (40, 1, EntityType.HARVESTER, None), + } + controller.builders[occupied_end] = 41 + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert occupied_end in {candidate[1] for candidate in candidates} + + +def test_orphan_cycle_exposes_one_disconnected_link_for_rebuild(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + cycle = { + Position(6, 5): Direction.SOUTH, + Position(6, 6): Direction.WEST, + Position(5, 6): Direction.EAST, + } + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = cycle + controller = CandidateController() + controller.entities = { + harvester: (50, 1, EntityType.HARVESTER, None), + Position(6, 5): (51, 1, EntityType.CONVEYOR, Direction.SOUTH), + Position(6, 6): (52, 1, EntityType.CONVEYOR, Direction.WEST), + Position(5, 6): (53, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert Position(6, 6) in {candidate[1] for candidate in candidates} + + +def test_late_repair_is_bounded_and_below_direct_threat_priority(): + allowed = BOT.late_route_repair_allowed + assert not allowed(BOT.LATE_ROUTE_REPAIR_START - 1, 500, 1) + assert allowed(BOT.LATE_ROUTE_REPAIR_START, + BOT.LATE_ROUTE_RESOURCE_FLOOR, + BOT.LATE_ROUTE_MAX_LINKS) + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, + BOT.LATE_ROUTE_RESOURCE_FLOOR - 1, 1) + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, 500, + BOT.LATE_ROUTE_MAX_LINKS + 1) + assert BOT.LATE_ROUTE_SCORE_CAP < 10 + assert BOT.LATE_ROUTE_FALLBACK_SCORE_CAP < 2 + + +def test_one_nearest_builder_claims_a_late_repair(): + target = Position(5, 5) + candidates = [ + (9, Position(3, 5)), + (7, Position(5, 3)), + (4, Position(9, 9)), + ] + assert BOT.choose_route_repairer(candidates, target) == 7 + assert BOT.choose_route_repairer(candidates, target, {7, 9}) == 4 + assert BOT.choose_route_repairer( + candidates, target, preferred_ids={9}) == 9 + assert BOT.choose_route_repairer([], target) is None + + +def test_experiment_preserves_protocol_and_has_no_fingerprints(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + source = "\n".join( + (BOT_DIR / name).read_text(encoding="utf-8").lower() + for name in ("main.py", "mapPathfinding.py") + ) + for fingerprint in ("string.map", "bridge.map", "duel.map", "ijti"): + assert fingerprint not in source + + +def method_ast(path, class_name, method_name): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return ast.dump(child, include_attributes=False) + raise AssertionError(f"missing {class_name}.{method_name}") + + +def test_parent_opening_conveyor_router_is_unchanged(): + parent = ROOT / "bots" / "meta-generalist-v1" / "mapPathfinding.py" + candidate = BOT_DIR / "mapPathfinding.py" + for method in ( + "conveyorTileCost", "fillConveyorDistTable", "routeConveyor"): + assert method_ast(candidate, "MapPathfinder", method) == method_ast( + parent, "MapPathfinder", method) + + +def test_late_repair_is_waller_only_and_heal_gated(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8") + assert "or not self._is_waller(ct)" in source + assert "or healScore > 0" in source + assert "routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP)" in source + + +def test_economy_observation_is_not_in_every_builders_map_scan(): + source = (BOT_DIR / "mapPathfinding.py").read_text(encoding="utf-8") + get_new = source[source.index("def getNewTiles"):source.index( + "def observeEconomyTile")] + assert "observeEconomyTile" not in get_new + main_source = (BOT_DIR / "main.py").read_text(encoding="utf-8") + assert "and self._is_waller(ct)" in main_source + assert "self.mapPf.refreshEconomyGraph(ct)" in main_source + + +if __name__ == "__main__": + test_only_actual_facing_chains_are_connected() + test_wrong_facing_harvester_neighbor_is_not_a_root() + test_dead_end_is_extended_but_cycle_is_rejected() + test_revisited_tiles_clear_stale_economy_memory() + test_served_harvester_produces_no_route_work() + test_wrong_facing_neighbor_does_not_hide_orphan_harvester() + test_friendly_builder_on_missing_link_keeps_route_candidate_alive() + test_orphan_cycle_exposes_one_disconnected_link_for_rebuild() + test_late_repair_is_bounded_and_below_direct_threat_priority() + test_one_nearest_builder_claims_a_late_repair() + test_experiment_preserves_protocol_and_has_no_fingerprints() + test_parent_opening_conveyor_router_is_unchanged() + test_late_repair_is_waller_only_and_heal_gated() + test_economy_observation_is_not_in_every_builders_map_scan() + print("lightweight waller route repair tests passed") diff --git a/tests/test_waller_route_repair.py b/tests/test_waller_route_repair.py new file mode 100644 index 0000000..5192527 --- /dev/null +++ b/tests/test_waller_route_repair.py @@ -0,0 +1,308 @@ +"""Deterministic mechanism tests for exp_waller_route_repair.""" + +from __future__ import annotations + +import importlib.util +import ast +import sys +from pathlib import Path + +from fcode import Direction, EntityType, Position + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_waller_route_repair" + + +def load_bot(): + sys.path.insert(0, str(BOT_DIR)) + try: + spec = importlib.util.spec_from_file_location( + "waller_route_repair_main", BOT_DIR / "main.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +BOT = load_bot() + + +def footprint(): + return BOT.core_footprint_positions(Position(0, 0)) + + +def test_only_actual_facing_chains_are_connected(): + conveyors = { + Position(2, 0): Direction.WEST, + Position(3, 0): Direction.WEST, + # Spatially touches the valid trunk, but points away from it. + Position(2, 1): Direction.SOUTH, + } + connected = BOT.proven_connected_conveyors(conveyors, footprint()) + assert connected == {Position(2, 0), Position(3, 0)} + + +def test_wrong_facing_harvester_neighbor_is_not_a_root(): + harvester = Position(5, 5) + conveyors = { + Position(4, 5): Direction.EAST, # outputs into the harvester + Position(5, 4): Direction.WEST, # accepts from the harvester + } + assert BOT.harvester_chain_roots(harvester, conveyors) == [ + Position(5, 4) + ] + + +def test_dead_end_is_extended_but_cycle_is_rejected(): + dead_end = { + Position(5, 5): Direction.WEST, + Position(4, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), dead_end, footprint(), set() + ) == ("dead_end", Position(3, 5)) + + cycle = { + Position(5, 5): Direction.EAST, + Position(6, 5): Direction.WEST, + } + assert BOT.trace_conveyor_chain( + Position(5, 5), cycle, footprint(), set() + ) == ("cycle", Position(5, 5)) + + +class ObservationController: + def __init__(self): + self.entities = {} + + def get_tile_building_id(self, position): + record = self.entities.get(position) + return None if record is None else record[0] + + def get_team(self, entity_id=None): + if entity_id is None: + return 1 + for record in self.entities.values(): + if record[0] == entity_id: + return record[1] + raise KeyError(entity_id) + + def get_entity_type(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[2] + raise KeyError(entity_id) + + def get_direction(self, entity_id): + for record in self.entities.values(): + if record[0] == entity_id: + return record[3] + raise KeyError(entity_id) + + +def test_revisited_tiles_clear_stale_economy_memory(): + pathfinder = BOT.MapPathfinder() + controller = ObservationController() + conveyor = Position(3, 3) + harvester = Position(4, 4) + controller.entities[conveyor] = ( + 10, 1, EntityType.CONVEYOR, Direction.WEST) + controller.entities[harvester] = ( + 11, 1, EntityType.HARVESTER, None) + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert pathfinder.knownTeamConveyors[conveyor] == Direction.WEST + assert harvester in pathfinder.knownTeamHarvesters + + controller.entities.clear() + pathfinder.observeEconomyTile(controller, conveyor) + pathfinder.observeEconomyTile(controller, harvester) + assert conveyor not in pathfinder.knownTeamConveyors + assert harvester not in pathfinder.knownTeamHarvesters + + +class CandidateController(ObservationController): + def __init__(self): + super().__init__() + self.builders = {} + + def is_in_vision(self, position): + return True + + def is_tile_passable(self, position): + return position not in self.builders + + def get_tile_builder_bot_id(self, position): + return self.builders.get(position) + + def get_team(self, entity_id=None): + if entity_id in self.builders.values(): + return 1 + return super().get_team(entity_id) + + +def test_served_harvester_produces_no_route_work(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(4, 0) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = { + Position(3, 0): Direction.WEST, + Position(2, 0): Direction.WEST, + } + controller = CandidateController() + controller.entities = { + harvester: (20, 1, EntityType.HARVESTER, None), + Position(3, 0): (21, 1, EntityType.CONVEYOR, Direction.WEST), + Position(2, 0): (22, 1, EntityType.CONVEYOR, Direction.WEST), + } + assert pathfinder.lateEconomyRouteCandidates(controller) == [] + + +def test_wrong_facing_neighbor_does_not_hide_orphan_harvester(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + wrong = Position(4, 5) + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = {wrong: Direction.EAST} + controller = CandidateController() + controller.entities = { + harvester: (30, 1, EntityType.HARVESTER, None), + wrong: (31, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + ends = {candidate[1] for candidate in candidates} + # The wrong-facing link is now a legal surgical rebuild target; it does + # not make the harvester appear served. + assert wrong in ends + assert ends == { + Position(4, 5), Position(5, 4), Position(5, 6), Position(6, 5) + } + + +def test_friendly_builder_on_missing_link_keeps_route_candidate_alive(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + occupied_end = Position(5, 4) + pathfinder.knownTeamHarvesters = {harvester} + controller = CandidateController() + controller.entities = { + harvester: (40, 1, EntityType.HARVESTER, None), + } + controller.builders[occupied_end] = 41 + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert occupied_end in {candidate[1] for candidate in candidates} + + +def test_orphan_cycle_exposes_one_disconnected_link_for_rebuild(): + pathfinder = BOT.MapPathfinder() + pathfinder.teamCore = Position(0, 0) + pathfinder.mapW = pathfinder.mapH = 12 + harvester = Position(5, 5) + cycle = { + Position(6, 5): Direction.SOUTH, + Position(6, 6): Direction.WEST, + Position(5, 6): Direction.EAST, + } + pathfinder.knownTeamHarvesters = {harvester} + pathfinder.knownTeamConveyors = cycle + controller = CandidateController() + controller.entities = { + harvester: (50, 1, EntityType.HARVESTER, None), + Position(6, 5): (51, 1, EntityType.CONVEYOR, Direction.SOUTH), + Position(6, 6): (52, 1, EntityType.CONVEYOR, Direction.WEST), + Position(5, 6): (53, 1, EntityType.CONVEYOR, Direction.EAST), + } + candidates = pathfinder.lateEconomyRouteCandidates(controller) + assert Position(6, 6) in {candidate[1] for candidate in candidates} + + +def test_late_repair_is_bounded_and_below_direct_threat_priority(): + allowed = BOT.late_route_repair_allowed + assert not allowed(BOT.LATE_ROUTE_REPAIR_START - 1, 500, 1) + assert allowed(BOT.LATE_ROUTE_REPAIR_START, + BOT.LATE_ROUTE_RESOURCE_FLOOR, + BOT.LATE_ROUTE_MAX_LINKS) + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, + BOT.LATE_ROUTE_RESOURCE_FLOOR - 1, 1) + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, 500, + BOT.LATE_ROUTE_MAX_LINKS + 1) + assert BOT.LATE_ROUTE_SCORE_CAP < 10 + assert BOT.LATE_ROUTE_FALLBACK_SCORE_CAP < 2 + + +def test_one_nearest_builder_claims_a_late_repair(): + target = Position(5, 5) + candidates = [ + (9, Position(3, 5)), + (7, Position(5, 3)), + (4, Position(9, 9)), + ] + assert BOT.choose_route_repairer(candidates, target) == 7 + assert BOT.choose_route_repairer(candidates, target, {7, 9}) == 4 + assert BOT.choose_route_repairer( + candidates, target, preferred_ids={9}) == 9 + assert BOT.choose_route_repairer([], target) is None + + +def test_experiment_preserves_protocol_and_has_no_fingerprints(): + parent = ROOT / "bots" / "meta-generalist-v1" + for name in ("initialSpawning.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + source = "\n".join( + (BOT_DIR / name).read_text(encoding="utf-8").lower() + for name in ("main.py", "mapPathfinding.py") + ) + for fingerprint in ("string.map", "bridge.map", "duel.map", "ijti"): + assert fingerprint not in source + + +def method_ast(path, class_name, method_name): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return ast.dump(child, include_attributes=False) + raise AssertionError(f"missing {class_name}.{method_name}") + + +def test_parent_opening_conveyor_router_is_unchanged(): + parent = ROOT / "bots" / "meta-generalist-v1" / "mapPathfinding.py" + candidate = BOT_DIR / "mapPathfinding.py" + for method in ( + "conveyorTileCost", "fillConveyorDistTable", "routeConveyor"): + assert method_ast(candidate, "MapPathfinder", method) == method_ast( + parent, "MapPathfinder", method) + + +def test_late_repair_is_waller_only_and_heal_gated(): + source = (BOT_DIR / "main.py").read_text(encoding="utf-8") + assert "or not self._is_waller(ct)" in source + assert "or healScore > 0" in source + assert "routeScore = min(routeScore, LATE_ROUTE_SCORE_CAP)" in source + + +if __name__ == "__main__": + test_only_actual_facing_chains_are_connected() + test_wrong_facing_harvester_neighbor_is_not_a_root() + test_dead_end_is_extended_but_cycle_is_rejected() + test_revisited_tiles_clear_stale_economy_memory() + test_served_harvester_produces_no_route_work() + test_wrong_facing_neighbor_does_not_hide_orphan_harvester() + test_friendly_builder_on_missing_link_keeps_route_candidate_alive() + test_orphan_cycle_exposes_one_disconnected_link_for_rebuild() + test_late_repair_is_bounded_and_below_direct_threat_priority() + test_one_nearest_builder_claims_a_late_repair() + test_experiment_preserves_protocol_and_has_no_fingerprints() + test_parent_opening_conveyor_router_is_unchanged() + test_late_repair_is_waller_only_and_heal_gated() + print("waller route repair tests passed") diff --git a/tests/test_waller_route_reserve.py b/tests/test_waller_route_reserve.py new file mode 100644 index 0000000..a3978e7 --- /dev/null +++ b/tests/test_waller_route_reserve.py @@ -0,0 +1,48 @@ +"""Protocol and scope tests for the higher-reserve late route repair.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +BOT_DIR = ROOT / "bots" / "exp_waller_route_reserve" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("waller_route_reserve", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_reserve_is_the_only_parent_change(): + parent = ROOT / "bots" / "exp_waller_route_light" + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (parent / name).read_bytes() + expected = (parent / "main.py").read_text(encoding="utf-8").replace( + "LATE_ROUTE_RESOURCE_FLOOR = 100", + "LATE_ROUTE_RESOURCE_FLOOR = 200", + 1, + ) + assert (BOT_DIR / "main.py").read_text(encoding="utf-8") == expected + + +def test_repair_preserves_a_real_combat_reserve(): + allowed = BOT.late_route_repair_allowed + assert not allowed(BOT.LATE_ROUTE_REPAIR_START, 199, 1) + assert allowed(BOT.LATE_ROUTE_REPAIR_START, 200, 1) + assert not allowed( + BOT.LATE_ROUTE_REPAIR_START, 500, BOT.LATE_ROUTE_MAX_LINKS + 1) + + +def test_no_fingerprints(): + source = "\n".join( + (BOT_DIR / name).read_text(encoding="utf-8").lower() + for name in ("main.py", "mapPathfinding.py") + ) + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_reserve_is_the_only_parent_change() + test_repair_preserves_a_real_combat_reserve() + test_no_fingerprints() diff --git a/tmp/pdfs/pantheon_postmortem/contact-01-04.jpg b/tmp/pdfs/pantheon_postmortem/contact-01-04.jpg new file mode 100644 index 0000000000000000000000000000000000000000..936af0f6e64a17d31b75b4194ad2e96cedb3f79a GIT binary patch literal 888111 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